mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 06:06:37 +03:00
Sync generated provider output
This commit is contained in:
@@ -10,7 +10,7 @@ Designs and iterates production-grade frontend interfaces. Real working code, co
|
||||
|
||||
You MUST do these steps before proceeding:
|
||||
|
||||
1. Run `node .agents/skills/impeccable/scripts/context.mjs` once per session. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task.
|
||||
1. Run `node .agents/skills/impeccable/scripts/context.mjs` once per session. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .agents/skills/impeccable/scripts/context.mjs --target <path>` instead. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task.
|
||||
2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read `reference/<command>.md` next. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects.
|
||||
3. Familiarize yourself with any existing design system, conventions, and components in the code. Read at least one project file (CSS / tokens / theme / a representative component or page). **Required even when you've loaded a sub-command reference in step 2.** Don't reinvent the wheel; use what's there when it works, branch out when the UX wins.
|
||||
4. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md.
|
||||
|
||||
@@ -10,7 +10,7 @@ Codex: run live helper commands, the app dev server, and any dependency-installi
|
||||
|
||||
Execute in order. No step skipped, no step reordered.
|
||||
|
||||
1. `live.mjs`: boot.
|
||||
1. `live.mjs`: boot. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .agents/skills/impeccable/scripts/live.mjs --target <path>` instead; then run the rest of this live session from the returned `projectRoot`.
|
||||
2. Open the app URL that serves `pageFile` (infer from `package.json`, docs, terminal output, or an open tab). Never use `serverPort`; it's the helper, not the app. **Cursor:** `browser_navigate` to that URL before polling; do not skip. **Other harnesses:** use the available browser tool; if the URL is uncertain, ask the user once.
|
||||
3. Poll loop with the default long timeout (600000 ms). After every event or `--reply`, run `live-poll.mjs` again immediately. Never pass a short `--timeout=`.
|
||||
|
||||
|
||||
@@ -5,11 +5,12 @@
|
||||
* init flow.
|
||||
*
|
||||
* Path resolution (first match wins):
|
||||
* 1. cwd, if PRODUCT.md or DESIGN.md is there
|
||||
* 2. .agents/context/ then docs/
|
||||
* 3. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user
|
||||
* 1. Active project root, if PRODUCT.md or DESIGN.md is there
|
||||
* 2. Active project .agents/context/ then docs/
|
||||
* 3. Monorepo root context, using the same order, as a per-file fallback
|
||||
* 4. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user
|
||||
* escape hatch, only consulted when defaults are empty
|
||||
* 4. cwd as a "nothing found" default
|
||||
* 5. Active project root as a "nothing found" default
|
||||
*
|
||||
* `resolveContextDir()` and `loadContext()` are also exported for the
|
||||
* server-side scripts (live.mjs, live-server.mjs) that need the structured
|
||||
@@ -19,10 +20,25 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseTargetOptions } from './lib/target-args.mjs';
|
||||
|
||||
const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md'];
|
||||
const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md'];
|
||||
const FALLBACK_DIRS = ['.agents/context', 'docs'];
|
||||
const MONOREPO_MARKER_FILES = ['pnpm-workspace.yaml', 'turbo.json', 'nx.json', 'lerna.json'];
|
||||
const MONOREPO_FALLBACK_PROJECT_DIRS = ['apps', 'packages'];
|
||||
const WORKSPACE_DISCOVERY_IGNORED_DIRS = new Set([
|
||||
'node_modules',
|
||||
'.git',
|
||||
'dist',
|
||||
'build',
|
||||
'.next',
|
||||
'.nuxt',
|
||||
'.svelte-kit',
|
||||
'.turbo',
|
||||
'.cache',
|
||||
'coverage',
|
||||
]);
|
||||
|
||||
// ─── Update check ──────────────────────────────────────────────────────────
|
||||
// Piggyback a lightweight skill-version check on the once-per-session boot.
|
||||
@@ -38,41 +54,600 @@ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // throttle the network poll to o
|
||||
const RENOTIFY_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000; // don't re-surface the same version for a week
|
||||
const FETCH_TIMEOUT_MS = 1200;
|
||||
|
||||
export function resolveContextDir(cwd = process.cwd()) {
|
||||
if (firstExisting(cwd, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return cwd;
|
||||
}
|
||||
for (const rel of FALLBACK_DIRS) {
|
||||
const candidate = path.resolve(cwd, rel);
|
||||
if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
const envDir = process.env.IMPECCABLE_CONTEXT_DIR;
|
||||
if (envDir && envDir.trim()) {
|
||||
const trimmed = envDir.trim();
|
||||
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
||||
}
|
||||
return cwd;
|
||||
export function resolveContextDir(cwd = process.cwd(), options = {}) {
|
||||
return resolveContext(cwd, options).contextDir;
|
||||
}
|
||||
|
||||
export function loadContext(cwd = process.cwd()) {
|
||||
const contextDir = resolveContextDir(cwd);
|
||||
const productPath = firstExisting(contextDir, PRODUCT_NAMES);
|
||||
const designPath = firstExisting(contextDir, DESIGN_NAMES);
|
||||
export function loadContext(cwd = process.cwd(), options = {}) {
|
||||
const resolved = resolveContext(cwd, options);
|
||||
const absCwd = path.resolve(cwd);
|
||||
const productPath = resolved.productPath;
|
||||
const designPath = resolved.designPath;
|
||||
const product = productPath ? safeRead(productPath) : null;
|
||||
const design = designPath ? safeRead(designPath) : null;
|
||||
return {
|
||||
hasProduct: !!product,
|
||||
product,
|
||||
productPath: productPath ? path.relative(cwd, productPath) : null,
|
||||
productPath: productPath ? path.relative(absCwd, productPath) : null,
|
||||
hasDesign: !!design,
|
||||
design,
|
||||
designPath: designPath ? path.relative(cwd, designPath) : null,
|
||||
contextDir,
|
||||
designPath: designPath ? path.relative(absCwd, designPath) : null,
|
||||
contextDir: resolved.contextDir,
|
||||
productContextDir: productPath ? path.dirname(productPath) : null,
|
||||
designContextDir: designPath ? path.dirname(designPath) : null,
|
||||
projectRoot: resolved.projectRoot,
|
||||
repoRoot: resolved.repoRoot,
|
||||
isMonorepo: resolved.isMonorepo,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveContext(cwd = process.cwd(), options = {}) {
|
||||
const absCwd = path.resolve(cwd);
|
||||
const project = resolveProject(absCwd, options);
|
||||
const projectContextDir = resolveLocalContextDir(project.projectRoot);
|
||||
const rootContextDir = project.isMonorepo && project.repoRoot !== project.projectRoot
|
||||
? resolveLocalContextDir(project.repoRoot)
|
||||
: null;
|
||||
|
||||
let productPath =
|
||||
(projectContextDir ? firstExisting(projectContextDir, PRODUCT_NAMES) : null)
|
||||
|| (rootContextDir ? firstExisting(rootContextDir, PRODUCT_NAMES) : null);
|
||||
let designPath =
|
||||
(projectContextDir ? firstExisting(projectContextDir, DESIGN_NAMES) : null)
|
||||
|| (rootContextDir ? firstExisting(rootContextDir, DESIGN_NAMES) : null);
|
||||
|
||||
let envContextDir = null;
|
||||
if (!productPath && !designPath) {
|
||||
envContextDir = resolveEnvContextDir(absCwd);
|
||||
if (envContextDir) {
|
||||
productPath = firstExisting(envContextDir, PRODUCT_NAMES);
|
||||
designPath = firstExisting(envContextDir, DESIGN_NAMES);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
contextDir: productPath
|
||||
? path.dirname(productPath)
|
||||
: designPath
|
||||
? path.dirname(designPath)
|
||||
: envContextDir || project.projectRoot,
|
||||
productPath,
|
||||
designPath,
|
||||
projectRoot: project.projectRoot,
|
||||
repoRoot: project.repoRoot,
|
||||
isMonorepo: project.isMonorepo,
|
||||
targetDir: project.targetDir,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveProjectRoot(cwd = process.cwd(), options = {}) {
|
||||
return resolveProject(cwd, options).projectRoot;
|
||||
}
|
||||
|
||||
export function resolveTargetSelection(cwd = process.cwd(), options = {}) {
|
||||
if (hasTargetOption(options)) return null;
|
||||
const project = resolveProject(cwd);
|
||||
if (
|
||||
!project.isMonorepo
|
||||
|| !project.projectRoot
|
||||
|| !project.repoRoot
|
||||
|| path.resolve(project.projectRoot) !== path.resolve(project.repoRoot)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
targetPath: null,
|
||||
projectRoot: project.projectRoot,
|
||||
repoRoot: project.repoRoot,
|
||||
targetCandidates: discoverTargetCandidates(project.repoRoot),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveProject(cwd = process.cwd(), options = {}) {
|
||||
const absCwd = path.resolve(cwd);
|
||||
const targetDir = resolveTargetDir(absCwd, options);
|
||||
let repoRoot = findMonorepoRoot(targetDir);
|
||||
if (!repoRoot && targetDir !== absCwd) {
|
||||
const cwdRepoRoot = findMonorepoRoot(absCwd);
|
||||
if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {
|
||||
repoRoot = cwdRepoRoot;
|
||||
}
|
||||
}
|
||||
if (!repoRoot) {
|
||||
return {
|
||||
targetDir,
|
||||
projectRoot: absCwd,
|
||||
repoRoot: absCwd,
|
||||
isMonorepo: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
targetDir,
|
||||
projectRoot: resolveWorkspaceProjectRoot(repoRoot, targetDir) || repoRoot,
|
||||
repoRoot,
|
||||
isMonorepo: true,
|
||||
};
|
||||
}
|
||||
|
||||
function isPathInside(candidate, root) {
|
||||
const rel = path.relative(root, candidate);
|
||||
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
|
||||
}
|
||||
|
||||
function resolveLocalContextDir(root) {
|
||||
if (firstExisting(root, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return root;
|
||||
}
|
||||
for (const rel of FALLBACK_DIRS) {
|
||||
const candidate = path.resolve(root, rel);
|
||||
if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveEnvContextDir(cwd) {
|
||||
const envDir = process.env.IMPECCABLE_CONTEXT_DIR;
|
||||
if (!envDir || !envDir.trim()) return null;
|
||||
const trimmed = envDir.trim();
|
||||
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
||||
}
|
||||
|
||||
function resolveTargetDir(cwd, options = {}) {
|
||||
const targetPath = options && typeof options === 'object' ? options.targetPath : null;
|
||||
if (!targetPath || !String(targetPath).trim()) return cwd;
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
try {
|
||||
const stat = fs.statSync(abs);
|
||||
return stat.isDirectory() ? abs : path.dirname(abs);
|
||||
} catch {
|
||||
return path.extname(abs) ? path.dirname(abs) : abs;
|
||||
}
|
||||
}
|
||||
|
||||
function findMonorepoRoot(startDir) {
|
||||
let dir = path.resolve(startDir);
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
while (true) {
|
||||
if (dir === homeDir) return null;
|
||||
if (isMonorepoRoot(dir)) return dir;
|
||||
if (hasGitBoundary(dir)) return null;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function isMonorepoRoot(dir) {
|
||||
if (readWorkspacePatterns(dir).some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) return true;
|
||||
if (!MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(dir, file)))) return false;
|
||||
return hasFallbackWorkspaceChildren(dir);
|
||||
}
|
||||
|
||||
function hasGitBoundary(dir) {
|
||||
return fs.existsSync(path.join(dir, '.git'));
|
||||
}
|
||||
|
||||
function hasFallbackWorkspaceChildren(dir) {
|
||||
for (const name of MONOREPO_FALLBACK_PROJECT_DIRS) {
|
||||
const base = path.join(dir, name);
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(base, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (entries.some((entry) => entry.isDirectory() && !isIgnoredWorkspaceDiscoveryDir(entry.name))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function discoverTargetCandidates(repoRoot) {
|
||||
const roots = new Map();
|
||||
for (const pattern of readWorkspacePatterns(repoRoot)) {
|
||||
for (const root of discoverRootsForPattern(repoRoot, pattern)) {
|
||||
roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root);
|
||||
}
|
||||
}
|
||||
if (MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(repoRoot, file)))) {
|
||||
for (const name of MONOREPO_FALLBACK_PROJECT_DIRS) {
|
||||
const base = path.join(repoRoot, name);
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(base, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
||||
const root = path.join(base, entry.name);
|
||||
roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...roots.entries()]
|
||||
.filter(([rel]) => rel && !rel.startsWith('..'))
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([rel, root]) => {
|
||||
const targetExample = findTargetExample(repoRoot, root);
|
||||
return {
|
||||
name: path.basename(root),
|
||||
path: rel,
|
||||
targetExample,
|
||||
...resolveCandidateContextSummary(repoRoot, root, targetExample),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function resolveCandidateContextSummary(repoRoot, projectRoot, targetPath) {
|
||||
const ctx = resolveContext(repoRoot, { targetPath });
|
||||
return {
|
||||
productStatus: contextSourceStatus(ctx.productPath, repoRoot, projectRoot),
|
||||
productPath: contextSourcePath(ctx.productPath, repoRoot),
|
||||
designStatus: contextSourceStatus(ctx.designPath, repoRoot, projectRoot),
|
||||
designPath: contextSourcePath(ctx.designPath, repoRoot),
|
||||
};
|
||||
}
|
||||
|
||||
function contextSourceStatus(filePath, repoRoot, projectRoot) {
|
||||
if (!filePath) return 'missing';
|
||||
const absPath = path.resolve(filePath);
|
||||
const absProjectRoot = path.resolve(projectRoot);
|
||||
const absRepoRoot = path.resolve(repoRoot);
|
||||
if (isPathInsideOrEqual(absPath, absProjectRoot)) {
|
||||
return path.dirname(absPath) === absProjectRoot ? 'child' : 'fallback';
|
||||
}
|
||||
if (absProjectRoot !== absRepoRoot && isPathInsideOrEqual(absPath, absRepoRoot)) {
|
||||
return 'inherited';
|
||||
}
|
||||
return 'fallback';
|
||||
}
|
||||
|
||||
function contextSourcePath(filePath, repoRoot) {
|
||||
if (!filePath) return null;
|
||||
const rel = path.relative(repoRoot, filePath);
|
||||
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {
|
||||
return rel.split(path.sep).join('/');
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function discoverRootsForPattern(repoRoot, rawPattern) {
|
||||
const pattern = normalizeWorkspacePattern(rawPattern);
|
||||
if (!pattern || pattern.startsWith('!')) return [];
|
||||
const segments = pattern.split('/').filter(Boolean);
|
||||
if (!segments.length) return [];
|
||||
const firstGlobIndex = segments.findIndex((segment) => segment.includes('*'));
|
||||
const literalPrefix = firstGlobIndex === -1 ? segments : segments.slice(0, firstGlobIndex);
|
||||
const base = path.join(repoRoot, ...literalPrefix);
|
||||
if (!fs.existsSync(base)) return [];
|
||||
if (segments.includes('**')) {
|
||||
const packageRoots = [];
|
||||
walkDirs(base, (dir) => {
|
||||
if (dir !== base && isCandidateProjectRoot(dir)) packageRoots.push(dir);
|
||||
});
|
||||
if (packageRoots.length) return packageRoots;
|
||||
return directChildDirs(base);
|
||||
}
|
||||
return expandSimplePattern(repoRoot, segments);
|
||||
}
|
||||
|
||||
function expandSimplePattern(repoRoot, patternSegments, index = 0, current = repoRoot) {
|
||||
if (index >= patternSegments.length) return fs.existsSync(current) ? [current] : [];
|
||||
const segment = patternSegments[index];
|
||||
if (!segment.includes('*')) {
|
||||
return expandSimplePattern(repoRoot, patternSegments, index + 1, path.join(current, segment));
|
||||
}
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(current, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const roots = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
||||
if (!segmentMatches(segment, entry.name)) continue;
|
||||
roots.push(...expandSimplePattern(repoRoot, patternSegments, index + 1, path.join(current, entry.name)));
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
function directChildDirs(dir) {
|
||||
try {
|
||||
return fs.readdirSync(dir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() && !isIgnoredWorkspaceDiscoveryDir(entry.name))
|
||||
.map((entry) => path.join(dir, entry.name));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function walkDirs(root, visit) {
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(root, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
||||
const dir = path.join(root, entry.name);
|
||||
visit(dir);
|
||||
walkDirs(dir, visit);
|
||||
}
|
||||
}
|
||||
|
||||
function isCandidateProjectRoot(dir) {
|
||||
return !!(
|
||||
fs.existsSync(path.join(dir, 'package.json'))
|
||||
|| firstExisting(dir, [...PRODUCT_NAMES, ...DESIGN_NAMES])
|
||||
|| fs.existsSync(path.join(dir, 'src'))
|
||||
|| fs.existsSync(path.join(dir, 'app'))
|
||||
|| fs.existsSync(path.join(dir, 'pages'))
|
||||
|| fs.existsSync(path.join(dir, 'public'))
|
||||
);
|
||||
}
|
||||
|
||||
function isIgnoredWorkspaceDiscoveryDir(name) {
|
||||
return name.startsWith('.') || WORKSPACE_DISCOVERY_IGNORED_DIRS.has(name);
|
||||
}
|
||||
|
||||
function findTargetExample(repoRoot, projectRoot) {
|
||||
const examples = [
|
||||
'src/App.jsx',
|
||||
'src/App.tsx',
|
||||
'src/main.jsx',
|
||||
'src/main.tsx',
|
||||
'src/index.jsx',
|
||||
'src/index.ts',
|
||||
'app/page.tsx',
|
||||
'pages/index.tsx',
|
||||
'public/index.html',
|
||||
];
|
||||
for (const rel of examples) {
|
||||
const abs = path.join(projectRoot, rel);
|
||||
if (fs.existsSync(abs)) return path.relative(repoRoot, abs).split(path.sep).join('/');
|
||||
}
|
||||
return path.relative(repoRoot, projectRoot).split(path.sep).join('/');
|
||||
}
|
||||
|
||||
function resolveWorkspaceProjectRoot(repoRoot, targetDir) {
|
||||
const rel = path.relative(repoRoot, targetDir);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return repoRoot;
|
||||
const relSegments = rel.split(path.sep).filter(Boolean);
|
||||
const patterns = readWorkspacePatterns(repoRoot);
|
||||
const excluded = isExcludedByWorkspacePattern(relSegments, patterns);
|
||||
if (!excluded) {
|
||||
for (const pattern of patterns) {
|
||||
const projectRoot = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern);
|
||||
if (projectRoot) return projectRoot;
|
||||
}
|
||||
}
|
||||
if (excluded) return repoRoot;
|
||||
if (
|
||||
relSegments.length >= 2
|
||||
&& MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0])
|
||||
) {
|
||||
return path.join(repoRoot, relSegments[0], relSegments[1]);
|
||||
}
|
||||
const nearest = nearestProjectLikeRoot(repoRoot, targetDir);
|
||||
if (nearest) return nearest;
|
||||
return repoRoot;
|
||||
}
|
||||
|
||||
function isExcludedByWorkspacePattern(relSegments, patterns) {
|
||||
return patterns.some((rawPattern) => {
|
||||
const pattern = normalizeWorkspacePattern(rawPattern);
|
||||
if (!pattern.startsWith('!')) return false;
|
||||
return workspacePatternMatchesRel(pattern.slice(1), relSegments);
|
||||
});
|
||||
}
|
||||
|
||||
function nearestProjectLikeRoot(repoRoot, targetDir) {
|
||||
let dir = path.resolve(targetDir);
|
||||
const stop = path.resolve(repoRoot);
|
||||
while (dir && dir !== stop) {
|
||||
if (
|
||||
firstExisting(dir, [...PRODUCT_NAMES, ...DESIGN_NAMES])
|
||||
|| fs.existsSync(path.join(dir, 'package.json'))
|
||||
) {
|
||||
return dir;
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function nearestPackageRootBetween(repoRoot, targetDir, stopDir) {
|
||||
let dir = path.resolve(targetDir);
|
||||
const stop = path.resolve(stopDir || repoRoot);
|
||||
const root = path.resolve(repoRoot);
|
||||
while (dir && dir !== stop && isPathInsideOrEqual(dir, root)) {
|
||||
if (fs.existsSync(path.join(dir, 'package.json'))) return dir;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPathInsideOrEqual(candidate, root) {
|
||||
return path.resolve(candidate) === path.resolve(root) || isPathInside(candidate, root);
|
||||
}
|
||||
|
||||
function workspacePatternMatchesRel(pattern, relSegments) {
|
||||
const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return false;
|
||||
if (patternSegments.includes('**')) {
|
||||
const firstGlobIndex = patternSegments.findIndex((segment) => segment.includes('*'));
|
||||
const literalPrefix = firstGlobIndex === -1
|
||||
? patternSegments
|
||||
: patternSegments.slice(0, firstGlobIndex);
|
||||
if (relSegments.length < literalPrefix.length + 1) return false;
|
||||
for (let i = 0; i < literalPrefix.length; i++) {
|
||||
if (!segmentMatches(literalPrefix[i], relSegments[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (relSegments.length < patternSegments.length) return false;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function readWorkspacePatterns(repoRoot) {
|
||||
return [
|
||||
...readPackageWorkspaces(repoRoot),
|
||||
...readPnpmWorkspaces(repoRoot),
|
||||
...readLernaWorkspaces(repoRoot),
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
function readPackageWorkspaces(repoRoot) {
|
||||
const pkg = readJson(path.join(repoRoot, 'package.json'));
|
||||
const workspaces = pkg?.workspaces;
|
||||
if (Array.isArray(workspaces)) return workspaces;
|
||||
if (Array.isArray(workspaces?.packages)) return workspaces.packages;
|
||||
return [];
|
||||
}
|
||||
|
||||
function readLernaWorkspaces(repoRoot) {
|
||||
const lerna = readJson(path.join(repoRoot, 'lerna.json'));
|
||||
return Array.isArray(lerna?.packages) ? lerna.packages : [];
|
||||
}
|
||||
|
||||
function readPnpmWorkspaces(repoRoot) {
|
||||
try {
|
||||
const body = fs.readFileSync(path.join(repoRoot, 'pnpm-workspace.yaml'), 'utf-8');
|
||||
const patterns = [];
|
||||
let inPackages = false;
|
||||
for (const line of body.split(/\r?\n/)) {
|
||||
const trimmed = stripYamlInlineComment(line).trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const flowMatch = trimmed.match(/^packages:\s*\[(.*)\]\s*$/);
|
||||
if (flowMatch) {
|
||||
patterns.push(...parseYamlFlowList(flowMatch[1]));
|
||||
inPackages = false;
|
||||
continue;
|
||||
}
|
||||
if (/^packages:\s*$/.test(trimmed)) {
|
||||
inPackages = true;
|
||||
continue;
|
||||
}
|
||||
if (inPackages && /^[A-Za-z0-9_-]+:\s*/.test(trimmed)) break;
|
||||
if (inPackages) {
|
||||
const match = trimmed.match(/^-\s*(.+)$/);
|
||||
if (match) patterns.push(unquoteYamlValue(match[1]));
|
||||
}
|
||||
}
|
||||
return patterns;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function stripYamlInlineComment(line) {
|
||||
let quote = null;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
if ((ch === '"' || ch === "'") && line[i - 1] !== '\\') {
|
||||
quote = quote === ch ? null : quote || ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === '#' && !quote) return line.slice(0, i);
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
function parseYamlFlowList(body) {
|
||||
const items = [];
|
||||
let quote = null;
|
||||
let current = '';
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
const ch = body[i];
|
||||
if ((ch === '"' || ch === "'") && body[i - 1] !== '\\') {
|
||||
quote = quote === ch ? null : quote || ch;
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === ',' && !quote) {
|
||||
const value = unquoteYamlValue(current);
|
||||
if (value) items.push(value);
|
||||
current = '';
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
}
|
||||
const value = unquoteYamlValue(current);
|
||||
if (value) items.push(value);
|
||||
return items;
|
||||
}
|
||||
|
||||
function unquoteYamlValue(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '');
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function projectRootFromWorkspacePattern(repoRoot, relSegments, rawPattern) {
|
||||
const pattern = normalizeWorkspacePattern(rawPattern);
|
||||
if (!pattern || pattern.startsWith('!')) return null;
|
||||
const patternSegments = pattern.split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return null;
|
||||
if (patternSegments.includes('**')) {
|
||||
return projectRootFromDoubleStarPattern(repoRoot, relSegments, patternSegments);
|
||||
}
|
||||
if (relSegments.length < patternSegments.length) return null;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return null;
|
||||
}
|
||||
return path.join(repoRoot, ...relSegments.slice(0, patternSegments.length));
|
||||
}
|
||||
|
||||
function projectRootFromDoubleStarPattern(repoRoot, relSegments, patternSegments) {
|
||||
const firstGlobIndex = patternSegments.findIndex((segment) => segment.includes('*'));
|
||||
const literalPrefix = firstGlobIndex === -1
|
||||
? patternSegments
|
||||
: patternSegments.slice(0, firstGlobIndex);
|
||||
if (relSegments.length < literalPrefix.length + 1) return null;
|
||||
for (let i = 0; i < literalPrefix.length; i++) {
|
||||
if (!segmentMatches(literalPrefix[i], relSegments[i])) return null;
|
||||
}
|
||||
const prefixDir = path.join(repoRoot, ...literalPrefix);
|
||||
const targetDir = path.join(repoRoot, ...relSegments);
|
||||
const packageRoot = nearestPackageRootBetween(repoRoot, targetDir, prefixDir);
|
||||
if (packageRoot) return packageRoot;
|
||||
return path.join(repoRoot, ...relSegments.slice(0, literalPrefix.length + 1));
|
||||
}
|
||||
|
||||
function normalizeWorkspacePattern(pattern) {
|
||||
return String(pattern || '')
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '')
|
||||
.replace(/^\.\//, '')
|
||||
.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function segmentMatches(patternSegment, relSegment) {
|
||||
if (patternSegment === '*') return true;
|
||||
if (!patternSegment.includes('*')) return patternSegment === relSegment;
|
||||
const re = new RegExp(`^${escapeRegExp(patternSegment).replace(/\\\*/g, '[^/]*')}$`);
|
||||
return re.test(relSegment);
|
||||
}
|
||||
|
||||
function firstExisting(dir, names) {
|
||||
for (const name of names) {
|
||||
const abs = path.join(dir, name);
|
||||
@@ -89,6 +664,10 @@ function safeRead(p) {
|
||||
}
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the register (`brand` or `product`) out of PRODUCT.md by looking
|
||||
* for a `## Register` section and reading the first non-empty line that
|
||||
@@ -233,7 +812,24 @@ async function computeUpdateDirective(now = Date.now()) {
|
||||
}
|
||||
|
||||
async function cli() {
|
||||
const ctx = loadContext(process.cwd());
|
||||
let cliOptions;
|
||||
try {
|
||||
cliOptions = parseCliOptions(process.argv.slice(2));
|
||||
} catch (err) {
|
||||
if (err?.name === 'TargetArgError') {
|
||||
process.stderr.write(`${err.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const targetProvided = hasTargetOption(cliOptions);
|
||||
const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null;
|
||||
const selection = resolveTargetSelection(process.cwd(), cliOptions);
|
||||
if (selection) {
|
||||
process.stdout.write(buildTargetSelectionDirective(selection) + '\n');
|
||||
process.exit(0);
|
||||
}
|
||||
const ctx = loadContext(process.cwd(), cliOptions);
|
||||
const updateDirective = await computeUpdateDirective();
|
||||
|
||||
if (!ctx.hasProduct) {
|
||||
@@ -244,6 +840,10 @@ async function cli() {
|
||||
'Stop the current task, load reference/init.md, and follow its ' +
|
||||
'instructions to write PRODUCT.md before resuming.',
|
||||
];
|
||||
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
|
||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||
parts.push(buildMissingTargetDirective());
|
||||
}
|
||||
if (updateDirective) parts.push(updateDirective);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
process.exit(0);
|
||||
@@ -252,6 +852,10 @@ async function cli() {
|
||||
if (ctx.hasDesign) {
|
||||
parts.push(`# DESIGN.md\n\n${ctx.design.trim()}`);
|
||||
}
|
||||
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
|
||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||
parts.push(buildMissingTargetDirective());
|
||||
}
|
||||
const register = extractRegister(ctx.product);
|
||||
const next = register
|
||||
? `NEXT STEP: This project's register is \`${register}\`. You MUST now read \`reference/${register}.md\` before producing any design output.`
|
||||
@@ -261,6 +865,60 @@ async function cli() {
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
}
|
||||
|
||||
function parseCliOptions(args) {
|
||||
return parseTargetOptions(args, { strict: true });
|
||||
}
|
||||
|
||||
function hasTargetOption(options) {
|
||||
return !!(options && typeof options.targetPath === 'string' && options.targetPath.trim());
|
||||
}
|
||||
|
||||
function pathExistsForTarget(cwd, targetPath) {
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
return fs.existsSync(abs);
|
||||
}
|
||||
|
||||
function buildResolvedContextDirective(ctx, options, { targetExists = null } = {}) {
|
||||
const targetPath = hasTargetOption(options) ? options.targetPath : null;
|
||||
return `RESOLVED_CONTEXT:\n${JSON.stringify({
|
||||
targetPath,
|
||||
...(targetPath ? { targetExists } : {}),
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
productPath: ctx.productPath,
|
||||
designPath: ctx.designPath,
|
||||
}, null, 2)}`;
|
||||
}
|
||||
|
||||
function shouldWarnMissingTarget(ctx, targetProvided, targetExists = null) {
|
||||
if (ctx.isMonorepo && targetProvided && targetExists === false) return true;
|
||||
return !!(
|
||||
ctx.isMonorepo
|
||||
&& (!targetProvided || targetExists === false)
|
||||
&& ctx.projectRoot
|
||||
&& ctx.repoRoot
|
||||
&& path.resolve(ctx.projectRoot) === path.resolve(ctx.repoRoot)
|
||||
);
|
||||
}
|
||||
|
||||
function buildMissingTargetDirective() {
|
||||
const script = process.argv[1] || 'context.mjs';
|
||||
return (
|
||||
'MONOREPO_TARGET_REQUIRED: This is a monorepo and context.mjs ran without --target. ' +
|
||||
'If the user named a file, route, or child app, do not answer from this output. ' +
|
||||
`Rerun \`node ${script} --target <path>\` and answer from that run's RESOLVED_CONTEXT fields.`
|
||||
);
|
||||
}
|
||||
|
||||
function buildTargetSelectionDirective(selection) {
|
||||
return (
|
||||
`TARGET_SELECTION_REQUIRED:\n${JSON.stringify(selection, null, 2)}\n\n` +
|
||||
'Show each app with its productStatus/productPath and designStatus/designPath so the user can see child overrides, inherited root files, fallback files, or missing files before choosing. ' +
|
||||
'Ask the user which app Impeccable should use, then rerun Impeccable helper commands from that child app cwd using this same scripts directory. ' +
|
||||
'Use `--target <path>` only as a fallback when changing cwd is not possible, or when the user explicitly named a file/path.'
|
||||
);
|
||||
}
|
||||
|
||||
// Run cli() only when this module is the entry point. Compare realpaths
|
||||
// rather than endsWith(): a loose suffix match also fires for unrelated
|
||||
// scripts like `load-context.mjs`, and realpath tolerates symlinked
|
||||
|
||||
@@ -1,50 +1,52 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { resolveProjectRoot } from '../context.mjs';
|
||||
|
||||
export const IMPECCABLE_DIR = '.impeccable';
|
||||
export const LIVE_DIR = 'live';
|
||||
export const CRITIQUE_DIR = 'critique';
|
||||
|
||||
export function getImpeccableDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, IMPECCABLE_DIR);
|
||||
export function getImpeccableDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), IMPECCABLE_DIR);
|
||||
}
|
||||
|
||||
export function getDesignSidecarPath(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), 'design.json');
|
||||
export function getDesignSidecarPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), 'design.json');
|
||||
}
|
||||
|
||||
export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd) {
|
||||
export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd, options = {}) {
|
||||
const projectRoot = resolveProjectRoot(cwd, options);
|
||||
const candidates = [
|
||||
getDesignSidecarPath(cwd),
|
||||
path.join(cwd, 'DESIGN.json'),
|
||||
getDesignSidecarPath(cwd, options),
|
||||
path.join(projectRoot, 'DESIGN.json'),
|
||||
];
|
||||
const contextLegacy = path.join(contextDir, 'DESIGN.json');
|
||||
if (!candidates.includes(contextLegacy)) candidates.push(contextLegacy);
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) {
|
||||
return firstExisting(getDesignSidecarCandidates(cwd, contextDir));
|
||||
export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd, options = {}) {
|
||||
return firstExisting(getDesignSidecarCandidates(cwd, contextDir, options));
|
||||
}
|
||||
|
||||
export function getLiveDir(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), LIVE_DIR);
|
||||
export function getLiveDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), LIVE_DIR);
|
||||
}
|
||||
|
||||
export function getLiveConfigPath(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'config.json');
|
||||
export function getLiveConfigPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'config.json');
|
||||
}
|
||||
|
||||
export function getLegacyLiveConfigPath(scriptsDir) {
|
||||
return path.join(scriptsDir, 'config.json');
|
||||
}
|
||||
|
||||
export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env } = {}) {
|
||||
export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env, targetPath } = {}) {
|
||||
if (env.IMPECCABLE_LIVE_CONFIG && env.IMPECCABLE_LIVE_CONFIG.trim()) {
|
||||
const configured = env.IMPECCABLE_LIVE_CONFIG.trim();
|
||||
return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured);
|
||||
}
|
||||
const primary = getLiveConfigPath(cwd);
|
||||
const primary = getLiveConfigPath(cwd, { targetPath });
|
||||
if (fs.existsSync(primary)) return primary;
|
||||
if (scriptsDir) {
|
||||
const legacy = getLegacyLiveConfigPath(scriptsDir);
|
||||
@@ -53,16 +55,16 @@ export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = p
|
||||
return primary;
|
||||
}
|
||||
|
||||
export function getLiveServerPath(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'server.json');
|
||||
export function getLiveServerPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'server.json');
|
||||
}
|
||||
|
||||
export function getLegacyLiveServerPath(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live.json');
|
||||
export function getLegacyLiveServerPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live.json');
|
||||
}
|
||||
|
||||
export function readLiveServerInfo(cwd = process.cwd()) {
|
||||
for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) {
|
||||
export function readLiveServerInfo(cwd = process.cwd(), options = {}) {
|
||||
for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) {
|
||||
try {
|
||||
const info = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
if (info && typeof info.pid === 'number' && !isLiveServerPidReachable(info.pid)) {
|
||||
@@ -88,37 +90,37 @@ export function isLiveServerPidReachable(pid) {
|
||||
}
|
||||
}
|
||||
|
||||
export function writeLiveServerInfo(cwd = process.cwd(), info) {
|
||||
const filePath = getLiveServerPath(cwd);
|
||||
export function writeLiveServerInfo(cwd = process.cwd(), info, options = {}) {
|
||||
const filePath = getLiveServerPath(cwd, options);
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(info));
|
||||
return filePath;
|
||||
}
|
||||
|
||||
export function removeLiveServerInfo(cwd = process.cwd()) {
|
||||
for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) {
|
||||
export function removeLiveServerInfo(cwd = process.cwd(), options = {}) {
|
||||
for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) {
|
||||
try { fs.unlinkSync(filePath); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
export function getLiveSessionsDir(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'sessions');
|
||||
export function getLiveSessionsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'sessions');
|
||||
}
|
||||
|
||||
export function getLegacyLiveSessionsDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live', 'sessions');
|
||||
export function getLegacyLiveSessionsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'sessions');
|
||||
}
|
||||
|
||||
export function getLiveAnnotationsDir(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'annotations');
|
||||
export function getLiveAnnotationsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'annotations');
|
||||
}
|
||||
|
||||
export function getCritiqueDir(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), CRITIQUE_DIR);
|
||||
export function getCritiqueDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), CRITIQUE_DIR);
|
||||
}
|
||||
|
||||
export function getLegacyLiveAnnotationsDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live', 'annotations');
|
||||
export function getLegacyLiveAnnotationsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'annotations');
|
||||
}
|
||||
|
||||
function firstExisting(paths) {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
class TargetArgError extends Error {
|
||||
constructor(message, code) {
|
||||
super(message);
|
||||
this.name = 'TargetArgError';
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseTargetPath(args = [], { strict = false } = {}) {
|
||||
let targetPath = null;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = String(args[i]);
|
||||
if (arg === '--target' || arg === '-t') {
|
||||
const next = args[i + 1];
|
||||
if (next && !String(next).startsWith('-')) {
|
||||
targetPath = String(next);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (strict) {
|
||||
throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith('--target=')) {
|
||||
const value = arg.slice('--target='.length);
|
||||
if (value) {
|
||||
targetPath = value;
|
||||
continue;
|
||||
}
|
||||
if (strict) {
|
||||
throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING');
|
||||
}
|
||||
}
|
||||
}
|
||||
return targetPath;
|
||||
}
|
||||
|
||||
export function parseTargetOptions(args = [], options = {}) {
|
||||
const targetPath = parseTargetPath(args, options);
|
||||
return targetPath ? { targetPath } : {};
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import path from 'node:path';
|
||||
import net from 'node:net';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseDesignMd } from './lib/design-parser.mjs';
|
||||
import { resolveContextDir } from './context.mjs';
|
||||
import { loadContext } from './context.mjs';
|
||||
import {
|
||||
assembleLiveBrowserScript,
|
||||
assertLiveBrowserScriptParts,
|
||||
@@ -55,7 +55,11 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
// PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
|
||||
// DESIGN sidecar is project-local at .impeccable/design.json, with legacy
|
||||
// DESIGN.json fallback for existing projects.
|
||||
const CONTEXT_DIR = resolveContextDir(process.cwd());
|
||||
const PROJECT_CONTEXT = loadContext(process.cwd());
|
||||
const CONTEXT_DIR = PROJECT_CONTEXT.contextDir;
|
||||
const DESIGN_MD_PATH = PROJECT_CONTEXT.designPath
|
||||
? path.resolve(process.cwd(), PROJECT_CONTEXT.designPath)
|
||||
: null;
|
||||
const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway
|
||||
const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s
|
||||
|
||||
@@ -371,10 +375,7 @@ function hasProjectContext() {
|
||||
// PRODUCT.md carries brand voice / anti-references — that's what determines
|
||||
// whether variants are brand-aware. DESIGN.md (visual tokens) is a separate
|
||||
// concern, surfaced by the design panel's own empty state.
|
||||
try {
|
||||
fs.accessSync(path.join(CONTEXT_DIR, 'PRODUCT.md'), fs.constants.R_OK);
|
||||
return true;
|
||||
} catch { return false; }
|
||||
return !!PROJECT_CONTEXT.hasProduct;
|
||||
}
|
||||
|
||||
function statOrNull(filePath) {
|
||||
@@ -549,8 +550,8 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
const token = url.searchParams.get('token');
|
||||
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
|
||||
|
||||
const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md');
|
||||
const jsonPath = resolveDesignSidecarPath(process.cwd(), CONTEXT_DIR) || getDesignSidecarPath(process.cwd());
|
||||
const mdPath = DESIGN_MD_PATH;
|
||||
const jsonPath = resolveDesignSidecarPath(process.cwd(), PROJECT_CONTEXT.designContextDir || CONTEXT_DIR) || getDesignSidecarPath(process.cwd());
|
||||
const mdStat = statOrNull(mdPath);
|
||||
const jsonStat = statOrNull(jsonPath);
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import path from 'node:path';
|
||||
import { resolveProjectRoot } from './context.mjs';
|
||||
import { parseTargetPath } from './lib/target-args.mjs';
|
||||
|
||||
export function resolveLiveTarget(cwd = process.cwd(), args = []) {
|
||||
const originalCwd = path.resolve(cwd);
|
||||
let targetPath = null;
|
||||
try {
|
||||
targetPath = parseTargetPath(args, { strict: true });
|
||||
} catch (err) {
|
||||
if (err?.name === 'TargetArgError') {
|
||||
process.stderr.write(`${err.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const absoluteTargetPath = targetPath
|
||||
? path.isAbsolute(targetPath) ? targetPath : path.resolve(originalCwd, targetPath)
|
||||
: null;
|
||||
const projectRoot = targetPath
|
||||
? resolveProjectRoot(originalCwd, { targetPath: absoluteTargetPath })
|
||||
: originalCwd;
|
||||
return {
|
||||
originalCwd,
|
||||
projectRoot,
|
||||
targetPath,
|
||||
absoluteTargetPath,
|
||||
targetOptions: absoluteTargetPath ? { targetPath: absoluteTargetPath } : {},
|
||||
};
|
||||
}
|
||||
@@ -21,14 +21,16 @@ import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadContext } from './context.mjs';
|
||||
import { loadContext, resolveTargetSelection } from './context.mjs';
|
||||
import { resolveFiles } from './live-inject.mjs';
|
||||
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
|
||||
import { resolveLiveTarget } from './live-target.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
async function liveCli() {
|
||||
const args = process.argv.slice(2);
|
||||
const liveTarget = resolveLiveTarget(process.cwd(), args);
|
||||
|
||||
if (args.includes('--help') || args.includes('-h')) {
|
||||
console.log(`Usage: node live.mjs
|
||||
@@ -38,37 +40,78 @@ Prepare everything for live variant mode in a single command:
|
||||
- Starts (or reuses) the live server in the background
|
||||
- Injects the browser script tag
|
||||
- Reads PRODUCT.md / DESIGN.md for project context
|
||||
- In monorepos, choose a child app first; --target <path> is the fallback/manual path
|
||||
|
||||
On success, prints a JSON blob with:
|
||||
{ ok, serverPort, serverToken, pageFile, hasContext, context }
|
||||
{ ok, serverPort, serverToken, pageFiles, projectRoot, repoRoot, targetPath, productPath, designPath }
|
||||
|
||||
On target_selection_required, prints:
|
||||
{ ok: false, error: "target_selection_required", targetCandidates }
|
||||
|
||||
On config_missing, prints:
|
||||
{ ok: false, error: "config_missing", configPath, hint }
|
||||
|
||||
The agent should then:
|
||||
1. If config_missing, create the config and re-run this script
|
||||
2. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort)
|
||||
3. Enter the poll loop: node live-poll.mjs`);
|
||||
1. If target_selection_required, ask which app to use and rerun from that child cwd
|
||||
2. If config_missing, create the config and re-run this script
|
||||
3. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort)
|
||||
4. Enter the poll loop: node live-poll.mjs`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const targetSelection = resolveTargetSelection(liveTarget.originalCwd, liveTarget.targetOptions);
|
||||
if (targetSelection) {
|
||||
console.log(JSON.stringify({
|
||||
ok: false,
|
||||
error: 'target_selection_required',
|
||||
...targetSelection,
|
||||
hint: 'Ask the user which app Impeccable should use, then rerun live from that child app cwd. Use --target <path> only as a fallback or explicit path diagnostic.',
|
||||
}, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const ctx = loadContext(liveTarget.originalCwd, liveTarget.targetOptions);
|
||||
const activeCwd = ctx.projectRoot;
|
||||
const outputTargetPath = liveTarget.targetPath || null;
|
||||
|
||||
const missingContext = missingLiveContext(ctx);
|
||||
if (missingContext.length > 0) {
|
||||
console.log(JSON.stringify({
|
||||
ok: false,
|
||||
error: 'context_missing',
|
||||
missing: missingContext,
|
||||
nextCommand: missingContext.includes('PRODUCT.md') ? 'init' : 'document',
|
||||
targetPath: outputTargetPath,
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
productPath: ctx.productPath,
|
||||
designPath: ctx.designPath,
|
||||
}, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 1. Check config (fail fast if missing — no point starting anything else)
|
||||
const checkOut = runScript('live-inject.mjs', ['--check']);
|
||||
const checkOut = runScript('live-inject.mjs', ['--check'], { cwd: activeCwd });
|
||||
const checkResult = safeParse(checkOut);
|
||||
if (!checkResult || !checkResult.ok) {
|
||||
console.log(JSON.stringify(checkResult || { ok: false, error: 'check_failed', raw: checkOut }));
|
||||
console.log(JSON.stringify({
|
||||
...(checkResult || { ok: false, error: 'check_failed', raw: checkOut }),
|
||||
targetPath: outputTargetPath,
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
}));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 2. Start server (or reuse existing)
|
||||
const serverInfo = ensureServerRunning();
|
||||
const serverInfo = ensureServerRunning(activeCwd);
|
||||
if (!serverInfo) {
|
||||
console.log(JSON.stringify({ ok: false, error: 'server_start_failed' }));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 3. Inject the script tag at the current port
|
||||
const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)]);
|
||||
const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)], { cwd: activeCwd });
|
||||
const injectResult = safeParse(injectOut);
|
||||
if (!injectResult || !injectResult.ok) {
|
||||
console.log(JSON.stringify({
|
||||
@@ -80,22 +123,23 @@ The agent should then:
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 4. Load PRODUCT.md + DESIGN.md context.
|
||||
const ctx = loadContext(process.cwd());
|
||||
|
||||
// 5. Compute drift-heal: compare resolved inject targets against the
|
||||
// 4. 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);
|
||||
const resolvedFiles = resolveFiles(activeCwd, checkResult.config);
|
||||
const drift = scanForDrift(activeCwd, resolvedFiles, checkResult.config);
|
||||
|
||||
// 6. Emit everything the agent needs
|
||||
// 5. Emit everything the agent needs
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
serverPort: serverInfo.port,
|
||||
serverToken: serverInfo.token,
|
||||
pageFiles: resolvedFiles,
|
||||
liveConfigPath: checkResult.path,
|
||||
configDrift: drift,
|
||||
targetPath: outputTargetPath,
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
hasProduct: ctx.hasProduct,
|
||||
product: ctx.product,
|
||||
productPath: ctx.productPath,
|
||||
@@ -105,6 +149,13 @@ The agent should then:
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
function missingLiveContext(ctx) {
|
||||
const missing = [];
|
||||
if (!ctx.hasProduct) missing.push('PRODUCT.md');
|
||||
if (!ctx.hasDesign) missing.push('DESIGN.md');
|
||||
return missing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drift-heal scan. Walks the project for HTML files under common
|
||||
* page-source directories (public/, src/, app/, pages/) and reports any
|
||||
@@ -201,11 +252,11 @@ function globToRegex(pattern) {
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function runScript(name, args) {
|
||||
function runScript(name, args, options = {}) {
|
||||
const scriptPath = path.join(__dirname, name);
|
||||
const cmd = `node "${scriptPath}" ${args.map(a => `"${a}"`).join(' ')}`;
|
||||
try {
|
||||
return execSync(cmd, { encoding: 'utf-8', cwd: process.cwd(), timeout: 15_000 });
|
||||
return execSync(cmd, { encoding: 'utf-8', cwd: options.cwd || process.cwd(), timeout: 15_000 });
|
||||
} catch (err) {
|
||||
// execSync throws on non-zero exit; return stdout if any
|
||||
return err.stdout || err.message || '';
|
||||
@@ -219,10 +270,10 @@ function safeParse(out) {
|
||||
/**
|
||||
* Return { pid, port, token } for the running live server, starting one if needed.
|
||||
*/
|
||||
function ensureServerRunning() {
|
||||
function ensureServerRunning(cwd = process.cwd()) {
|
||||
// Try to reuse an existing server
|
||||
try {
|
||||
const existing = readLiveServerInfo(process.cwd())?.info;
|
||||
const existing = readLiveServerInfo(cwd)?.info;
|
||||
if (existing && existing.pid) {
|
||||
try {
|
||||
process.kill(existing.pid, 0); // throws if dead
|
||||
@@ -232,7 +283,7 @@ function ensureServerRunning() {
|
||||
} catch { /* no PID file */ }
|
||||
|
||||
// Start a new server
|
||||
const out = runScript('live-server.mjs', ['--background']);
|
||||
const out = runScript('live-server.mjs', ['--background'], { cwd });
|
||||
return safeParse(out);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ Designs and iterates production-grade frontend interfaces. Real working code, co
|
||||
|
||||
You MUST do these steps before proceeding:
|
||||
|
||||
1. Run `node .claude/skills/impeccable/scripts/context.mjs` once per session. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task.
|
||||
1. Run `node .claude/skills/impeccable/scripts/context.mjs` once per session. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .claude/skills/impeccable/scripts/context.mjs --target <path>` instead. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task.
|
||||
2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read `reference/<command>.md` next. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects.
|
||||
3. Familiarize yourself with any existing design system, conventions, and components in the code. Read at least one project file (CSS / tokens / theme / a representative component or page). **Required even when you've loaded a sub-command reference in step 2.** Don't reinvent the wheel; use what's there when it works, branch out when the UX wins.
|
||||
4. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md.
|
||||
|
||||
@@ -8,7 +8,7 @@ A running dev server with hot module replacement (Vite, Next.js, Bun, etc.), OR
|
||||
|
||||
Execute in order. No step skipped, no step reordered.
|
||||
|
||||
1. `live.mjs`: boot.
|
||||
1. `live.mjs`: boot. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .claude/skills/impeccable/scripts/live.mjs --target <path>` instead; then run the rest of this live session from the returned `projectRoot`.
|
||||
2. Open the app URL that serves `pageFile` (infer from `package.json`, docs, terminal output, or an open tab). Never use `serverPort`; it's the helper, not the app. **Cursor:** `browser_navigate` to that URL before polling; do not skip. **Other harnesses:** use the available browser tool; if the URL is uncertain, ask the user once.
|
||||
3. Poll loop with the default long timeout (600000 ms). After every event or `--reply`, run `live-poll.mjs` again immediately. Never pass a short `--timeout=`.
|
||||
|
||||
|
||||
@@ -5,11 +5,12 @@
|
||||
* init flow.
|
||||
*
|
||||
* Path resolution (first match wins):
|
||||
* 1. cwd, if PRODUCT.md or DESIGN.md is there
|
||||
* 2. .agents/context/ then docs/
|
||||
* 3. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user
|
||||
* 1. Active project root, if PRODUCT.md or DESIGN.md is there
|
||||
* 2. Active project .agents/context/ then docs/
|
||||
* 3. Monorepo root context, using the same order, as a per-file fallback
|
||||
* 4. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user
|
||||
* escape hatch, only consulted when defaults are empty
|
||||
* 4. cwd as a "nothing found" default
|
||||
* 5. Active project root as a "nothing found" default
|
||||
*
|
||||
* `resolveContextDir()` and `loadContext()` are also exported for the
|
||||
* server-side scripts (live.mjs, live-server.mjs) that need the structured
|
||||
@@ -19,10 +20,25 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseTargetOptions } from './lib/target-args.mjs';
|
||||
|
||||
const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md'];
|
||||
const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md'];
|
||||
const FALLBACK_DIRS = ['.agents/context', 'docs'];
|
||||
const MONOREPO_MARKER_FILES = ['pnpm-workspace.yaml', 'turbo.json', 'nx.json', 'lerna.json'];
|
||||
const MONOREPO_FALLBACK_PROJECT_DIRS = ['apps', 'packages'];
|
||||
const WORKSPACE_DISCOVERY_IGNORED_DIRS = new Set([
|
||||
'node_modules',
|
||||
'.git',
|
||||
'dist',
|
||||
'build',
|
||||
'.next',
|
||||
'.nuxt',
|
||||
'.svelte-kit',
|
||||
'.turbo',
|
||||
'.cache',
|
||||
'coverage',
|
||||
]);
|
||||
|
||||
// ─── Update check ──────────────────────────────────────────────────────────
|
||||
// Piggyback a lightweight skill-version check on the once-per-session boot.
|
||||
@@ -38,41 +54,600 @@ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // throttle the network poll to o
|
||||
const RENOTIFY_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000; // don't re-surface the same version for a week
|
||||
const FETCH_TIMEOUT_MS = 1200;
|
||||
|
||||
export function resolveContextDir(cwd = process.cwd()) {
|
||||
if (firstExisting(cwd, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return cwd;
|
||||
}
|
||||
for (const rel of FALLBACK_DIRS) {
|
||||
const candidate = path.resolve(cwd, rel);
|
||||
if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
const envDir = process.env.IMPECCABLE_CONTEXT_DIR;
|
||||
if (envDir && envDir.trim()) {
|
||||
const trimmed = envDir.trim();
|
||||
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
||||
}
|
||||
return cwd;
|
||||
export function resolveContextDir(cwd = process.cwd(), options = {}) {
|
||||
return resolveContext(cwd, options).contextDir;
|
||||
}
|
||||
|
||||
export function loadContext(cwd = process.cwd()) {
|
||||
const contextDir = resolveContextDir(cwd);
|
||||
const productPath = firstExisting(contextDir, PRODUCT_NAMES);
|
||||
const designPath = firstExisting(contextDir, DESIGN_NAMES);
|
||||
export function loadContext(cwd = process.cwd(), options = {}) {
|
||||
const resolved = resolveContext(cwd, options);
|
||||
const absCwd = path.resolve(cwd);
|
||||
const productPath = resolved.productPath;
|
||||
const designPath = resolved.designPath;
|
||||
const product = productPath ? safeRead(productPath) : null;
|
||||
const design = designPath ? safeRead(designPath) : null;
|
||||
return {
|
||||
hasProduct: !!product,
|
||||
product,
|
||||
productPath: productPath ? path.relative(cwd, productPath) : null,
|
||||
productPath: productPath ? path.relative(absCwd, productPath) : null,
|
||||
hasDesign: !!design,
|
||||
design,
|
||||
designPath: designPath ? path.relative(cwd, designPath) : null,
|
||||
contextDir,
|
||||
designPath: designPath ? path.relative(absCwd, designPath) : null,
|
||||
contextDir: resolved.contextDir,
|
||||
productContextDir: productPath ? path.dirname(productPath) : null,
|
||||
designContextDir: designPath ? path.dirname(designPath) : null,
|
||||
projectRoot: resolved.projectRoot,
|
||||
repoRoot: resolved.repoRoot,
|
||||
isMonorepo: resolved.isMonorepo,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveContext(cwd = process.cwd(), options = {}) {
|
||||
const absCwd = path.resolve(cwd);
|
||||
const project = resolveProject(absCwd, options);
|
||||
const projectContextDir = resolveLocalContextDir(project.projectRoot);
|
||||
const rootContextDir = project.isMonorepo && project.repoRoot !== project.projectRoot
|
||||
? resolveLocalContextDir(project.repoRoot)
|
||||
: null;
|
||||
|
||||
let productPath =
|
||||
(projectContextDir ? firstExisting(projectContextDir, PRODUCT_NAMES) : null)
|
||||
|| (rootContextDir ? firstExisting(rootContextDir, PRODUCT_NAMES) : null);
|
||||
let designPath =
|
||||
(projectContextDir ? firstExisting(projectContextDir, DESIGN_NAMES) : null)
|
||||
|| (rootContextDir ? firstExisting(rootContextDir, DESIGN_NAMES) : null);
|
||||
|
||||
let envContextDir = null;
|
||||
if (!productPath && !designPath) {
|
||||
envContextDir = resolveEnvContextDir(absCwd);
|
||||
if (envContextDir) {
|
||||
productPath = firstExisting(envContextDir, PRODUCT_NAMES);
|
||||
designPath = firstExisting(envContextDir, DESIGN_NAMES);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
contextDir: productPath
|
||||
? path.dirname(productPath)
|
||||
: designPath
|
||||
? path.dirname(designPath)
|
||||
: envContextDir || project.projectRoot,
|
||||
productPath,
|
||||
designPath,
|
||||
projectRoot: project.projectRoot,
|
||||
repoRoot: project.repoRoot,
|
||||
isMonorepo: project.isMonorepo,
|
||||
targetDir: project.targetDir,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveProjectRoot(cwd = process.cwd(), options = {}) {
|
||||
return resolveProject(cwd, options).projectRoot;
|
||||
}
|
||||
|
||||
export function resolveTargetSelection(cwd = process.cwd(), options = {}) {
|
||||
if (hasTargetOption(options)) return null;
|
||||
const project = resolveProject(cwd);
|
||||
if (
|
||||
!project.isMonorepo
|
||||
|| !project.projectRoot
|
||||
|| !project.repoRoot
|
||||
|| path.resolve(project.projectRoot) !== path.resolve(project.repoRoot)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
targetPath: null,
|
||||
projectRoot: project.projectRoot,
|
||||
repoRoot: project.repoRoot,
|
||||
targetCandidates: discoverTargetCandidates(project.repoRoot),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveProject(cwd = process.cwd(), options = {}) {
|
||||
const absCwd = path.resolve(cwd);
|
||||
const targetDir = resolveTargetDir(absCwd, options);
|
||||
let repoRoot = findMonorepoRoot(targetDir);
|
||||
if (!repoRoot && targetDir !== absCwd) {
|
||||
const cwdRepoRoot = findMonorepoRoot(absCwd);
|
||||
if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {
|
||||
repoRoot = cwdRepoRoot;
|
||||
}
|
||||
}
|
||||
if (!repoRoot) {
|
||||
return {
|
||||
targetDir,
|
||||
projectRoot: absCwd,
|
||||
repoRoot: absCwd,
|
||||
isMonorepo: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
targetDir,
|
||||
projectRoot: resolveWorkspaceProjectRoot(repoRoot, targetDir) || repoRoot,
|
||||
repoRoot,
|
||||
isMonorepo: true,
|
||||
};
|
||||
}
|
||||
|
||||
function isPathInside(candidate, root) {
|
||||
const rel = path.relative(root, candidate);
|
||||
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
|
||||
}
|
||||
|
||||
function resolveLocalContextDir(root) {
|
||||
if (firstExisting(root, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return root;
|
||||
}
|
||||
for (const rel of FALLBACK_DIRS) {
|
||||
const candidate = path.resolve(root, rel);
|
||||
if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveEnvContextDir(cwd) {
|
||||
const envDir = process.env.IMPECCABLE_CONTEXT_DIR;
|
||||
if (!envDir || !envDir.trim()) return null;
|
||||
const trimmed = envDir.trim();
|
||||
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
||||
}
|
||||
|
||||
function resolveTargetDir(cwd, options = {}) {
|
||||
const targetPath = options && typeof options === 'object' ? options.targetPath : null;
|
||||
if (!targetPath || !String(targetPath).trim()) return cwd;
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
try {
|
||||
const stat = fs.statSync(abs);
|
||||
return stat.isDirectory() ? abs : path.dirname(abs);
|
||||
} catch {
|
||||
return path.extname(abs) ? path.dirname(abs) : abs;
|
||||
}
|
||||
}
|
||||
|
||||
function findMonorepoRoot(startDir) {
|
||||
let dir = path.resolve(startDir);
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
while (true) {
|
||||
if (dir === homeDir) return null;
|
||||
if (isMonorepoRoot(dir)) return dir;
|
||||
if (hasGitBoundary(dir)) return null;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function isMonorepoRoot(dir) {
|
||||
if (readWorkspacePatterns(dir).some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) return true;
|
||||
if (!MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(dir, file)))) return false;
|
||||
return hasFallbackWorkspaceChildren(dir);
|
||||
}
|
||||
|
||||
function hasGitBoundary(dir) {
|
||||
return fs.existsSync(path.join(dir, '.git'));
|
||||
}
|
||||
|
||||
function hasFallbackWorkspaceChildren(dir) {
|
||||
for (const name of MONOREPO_FALLBACK_PROJECT_DIRS) {
|
||||
const base = path.join(dir, name);
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(base, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (entries.some((entry) => entry.isDirectory() && !isIgnoredWorkspaceDiscoveryDir(entry.name))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function discoverTargetCandidates(repoRoot) {
|
||||
const roots = new Map();
|
||||
for (const pattern of readWorkspacePatterns(repoRoot)) {
|
||||
for (const root of discoverRootsForPattern(repoRoot, pattern)) {
|
||||
roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root);
|
||||
}
|
||||
}
|
||||
if (MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(repoRoot, file)))) {
|
||||
for (const name of MONOREPO_FALLBACK_PROJECT_DIRS) {
|
||||
const base = path.join(repoRoot, name);
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(base, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
||||
const root = path.join(base, entry.name);
|
||||
roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...roots.entries()]
|
||||
.filter(([rel]) => rel && !rel.startsWith('..'))
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([rel, root]) => {
|
||||
const targetExample = findTargetExample(repoRoot, root);
|
||||
return {
|
||||
name: path.basename(root),
|
||||
path: rel,
|
||||
targetExample,
|
||||
...resolveCandidateContextSummary(repoRoot, root, targetExample),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function resolveCandidateContextSummary(repoRoot, projectRoot, targetPath) {
|
||||
const ctx = resolveContext(repoRoot, { targetPath });
|
||||
return {
|
||||
productStatus: contextSourceStatus(ctx.productPath, repoRoot, projectRoot),
|
||||
productPath: contextSourcePath(ctx.productPath, repoRoot),
|
||||
designStatus: contextSourceStatus(ctx.designPath, repoRoot, projectRoot),
|
||||
designPath: contextSourcePath(ctx.designPath, repoRoot),
|
||||
};
|
||||
}
|
||||
|
||||
function contextSourceStatus(filePath, repoRoot, projectRoot) {
|
||||
if (!filePath) return 'missing';
|
||||
const absPath = path.resolve(filePath);
|
||||
const absProjectRoot = path.resolve(projectRoot);
|
||||
const absRepoRoot = path.resolve(repoRoot);
|
||||
if (isPathInsideOrEqual(absPath, absProjectRoot)) {
|
||||
return path.dirname(absPath) === absProjectRoot ? 'child' : 'fallback';
|
||||
}
|
||||
if (absProjectRoot !== absRepoRoot && isPathInsideOrEqual(absPath, absRepoRoot)) {
|
||||
return 'inherited';
|
||||
}
|
||||
return 'fallback';
|
||||
}
|
||||
|
||||
function contextSourcePath(filePath, repoRoot) {
|
||||
if (!filePath) return null;
|
||||
const rel = path.relative(repoRoot, filePath);
|
||||
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {
|
||||
return rel.split(path.sep).join('/');
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function discoverRootsForPattern(repoRoot, rawPattern) {
|
||||
const pattern = normalizeWorkspacePattern(rawPattern);
|
||||
if (!pattern || pattern.startsWith('!')) return [];
|
||||
const segments = pattern.split('/').filter(Boolean);
|
||||
if (!segments.length) return [];
|
||||
const firstGlobIndex = segments.findIndex((segment) => segment.includes('*'));
|
||||
const literalPrefix = firstGlobIndex === -1 ? segments : segments.slice(0, firstGlobIndex);
|
||||
const base = path.join(repoRoot, ...literalPrefix);
|
||||
if (!fs.existsSync(base)) return [];
|
||||
if (segments.includes('**')) {
|
||||
const packageRoots = [];
|
||||
walkDirs(base, (dir) => {
|
||||
if (dir !== base && isCandidateProjectRoot(dir)) packageRoots.push(dir);
|
||||
});
|
||||
if (packageRoots.length) return packageRoots;
|
||||
return directChildDirs(base);
|
||||
}
|
||||
return expandSimplePattern(repoRoot, segments);
|
||||
}
|
||||
|
||||
function expandSimplePattern(repoRoot, patternSegments, index = 0, current = repoRoot) {
|
||||
if (index >= patternSegments.length) return fs.existsSync(current) ? [current] : [];
|
||||
const segment = patternSegments[index];
|
||||
if (!segment.includes('*')) {
|
||||
return expandSimplePattern(repoRoot, patternSegments, index + 1, path.join(current, segment));
|
||||
}
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(current, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const roots = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
||||
if (!segmentMatches(segment, entry.name)) continue;
|
||||
roots.push(...expandSimplePattern(repoRoot, patternSegments, index + 1, path.join(current, entry.name)));
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
function directChildDirs(dir) {
|
||||
try {
|
||||
return fs.readdirSync(dir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() && !isIgnoredWorkspaceDiscoveryDir(entry.name))
|
||||
.map((entry) => path.join(dir, entry.name));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function walkDirs(root, visit) {
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(root, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
||||
const dir = path.join(root, entry.name);
|
||||
visit(dir);
|
||||
walkDirs(dir, visit);
|
||||
}
|
||||
}
|
||||
|
||||
function isCandidateProjectRoot(dir) {
|
||||
return !!(
|
||||
fs.existsSync(path.join(dir, 'package.json'))
|
||||
|| firstExisting(dir, [...PRODUCT_NAMES, ...DESIGN_NAMES])
|
||||
|| fs.existsSync(path.join(dir, 'src'))
|
||||
|| fs.existsSync(path.join(dir, 'app'))
|
||||
|| fs.existsSync(path.join(dir, 'pages'))
|
||||
|| fs.existsSync(path.join(dir, 'public'))
|
||||
);
|
||||
}
|
||||
|
||||
function isIgnoredWorkspaceDiscoveryDir(name) {
|
||||
return name.startsWith('.') || WORKSPACE_DISCOVERY_IGNORED_DIRS.has(name);
|
||||
}
|
||||
|
||||
function findTargetExample(repoRoot, projectRoot) {
|
||||
const examples = [
|
||||
'src/App.jsx',
|
||||
'src/App.tsx',
|
||||
'src/main.jsx',
|
||||
'src/main.tsx',
|
||||
'src/index.jsx',
|
||||
'src/index.ts',
|
||||
'app/page.tsx',
|
||||
'pages/index.tsx',
|
||||
'public/index.html',
|
||||
];
|
||||
for (const rel of examples) {
|
||||
const abs = path.join(projectRoot, rel);
|
||||
if (fs.existsSync(abs)) return path.relative(repoRoot, abs).split(path.sep).join('/');
|
||||
}
|
||||
return path.relative(repoRoot, projectRoot).split(path.sep).join('/');
|
||||
}
|
||||
|
||||
function resolveWorkspaceProjectRoot(repoRoot, targetDir) {
|
||||
const rel = path.relative(repoRoot, targetDir);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return repoRoot;
|
||||
const relSegments = rel.split(path.sep).filter(Boolean);
|
||||
const patterns = readWorkspacePatterns(repoRoot);
|
||||
const excluded = isExcludedByWorkspacePattern(relSegments, patterns);
|
||||
if (!excluded) {
|
||||
for (const pattern of patterns) {
|
||||
const projectRoot = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern);
|
||||
if (projectRoot) return projectRoot;
|
||||
}
|
||||
}
|
||||
if (excluded) return repoRoot;
|
||||
if (
|
||||
relSegments.length >= 2
|
||||
&& MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0])
|
||||
) {
|
||||
return path.join(repoRoot, relSegments[0], relSegments[1]);
|
||||
}
|
||||
const nearest = nearestProjectLikeRoot(repoRoot, targetDir);
|
||||
if (nearest) return nearest;
|
||||
return repoRoot;
|
||||
}
|
||||
|
||||
function isExcludedByWorkspacePattern(relSegments, patterns) {
|
||||
return patterns.some((rawPattern) => {
|
||||
const pattern = normalizeWorkspacePattern(rawPattern);
|
||||
if (!pattern.startsWith('!')) return false;
|
||||
return workspacePatternMatchesRel(pattern.slice(1), relSegments);
|
||||
});
|
||||
}
|
||||
|
||||
function nearestProjectLikeRoot(repoRoot, targetDir) {
|
||||
let dir = path.resolve(targetDir);
|
||||
const stop = path.resolve(repoRoot);
|
||||
while (dir && dir !== stop) {
|
||||
if (
|
||||
firstExisting(dir, [...PRODUCT_NAMES, ...DESIGN_NAMES])
|
||||
|| fs.existsSync(path.join(dir, 'package.json'))
|
||||
) {
|
||||
return dir;
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function nearestPackageRootBetween(repoRoot, targetDir, stopDir) {
|
||||
let dir = path.resolve(targetDir);
|
||||
const stop = path.resolve(stopDir || repoRoot);
|
||||
const root = path.resolve(repoRoot);
|
||||
while (dir && dir !== stop && isPathInsideOrEqual(dir, root)) {
|
||||
if (fs.existsSync(path.join(dir, 'package.json'))) return dir;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPathInsideOrEqual(candidate, root) {
|
||||
return path.resolve(candidate) === path.resolve(root) || isPathInside(candidate, root);
|
||||
}
|
||||
|
||||
function workspacePatternMatchesRel(pattern, relSegments) {
|
||||
const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return false;
|
||||
if (patternSegments.includes('**')) {
|
||||
const firstGlobIndex = patternSegments.findIndex((segment) => segment.includes('*'));
|
||||
const literalPrefix = firstGlobIndex === -1
|
||||
? patternSegments
|
||||
: patternSegments.slice(0, firstGlobIndex);
|
||||
if (relSegments.length < literalPrefix.length + 1) return false;
|
||||
for (let i = 0; i < literalPrefix.length; i++) {
|
||||
if (!segmentMatches(literalPrefix[i], relSegments[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (relSegments.length < patternSegments.length) return false;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function readWorkspacePatterns(repoRoot) {
|
||||
return [
|
||||
...readPackageWorkspaces(repoRoot),
|
||||
...readPnpmWorkspaces(repoRoot),
|
||||
...readLernaWorkspaces(repoRoot),
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
function readPackageWorkspaces(repoRoot) {
|
||||
const pkg = readJson(path.join(repoRoot, 'package.json'));
|
||||
const workspaces = pkg?.workspaces;
|
||||
if (Array.isArray(workspaces)) return workspaces;
|
||||
if (Array.isArray(workspaces?.packages)) return workspaces.packages;
|
||||
return [];
|
||||
}
|
||||
|
||||
function readLernaWorkspaces(repoRoot) {
|
||||
const lerna = readJson(path.join(repoRoot, 'lerna.json'));
|
||||
return Array.isArray(lerna?.packages) ? lerna.packages : [];
|
||||
}
|
||||
|
||||
function readPnpmWorkspaces(repoRoot) {
|
||||
try {
|
||||
const body = fs.readFileSync(path.join(repoRoot, 'pnpm-workspace.yaml'), 'utf-8');
|
||||
const patterns = [];
|
||||
let inPackages = false;
|
||||
for (const line of body.split(/\r?\n/)) {
|
||||
const trimmed = stripYamlInlineComment(line).trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const flowMatch = trimmed.match(/^packages:\s*\[(.*)\]\s*$/);
|
||||
if (flowMatch) {
|
||||
patterns.push(...parseYamlFlowList(flowMatch[1]));
|
||||
inPackages = false;
|
||||
continue;
|
||||
}
|
||||
if (/^packages:\s*$/.test(trimmed)) {
|
||||
inPackages = true;
|
||||
continue;
|
||||
}
|
||||
if (inPackages && /^[A-Za-z0-9_-]+:\s*/.test(trimmed)) break;
|
||||
if (inPackages) {
|
||||
const match = trimmed.match(/^-\s*(.+)$/);
|
||||
if (match) patterns.push(unquoteYamlValue(match[1]));
|
||||
}
|
||||
}
|
||||
return patterns;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function stripYamlInlineComment(line) {
|
||||
let quote = null;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
if ((ch === '"' || ch === "'") && line[i - 1] !== '\\') {
|
||||
quote = quote === ch ? null : quote || ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === '#' && !quote) return line.slice(0, i);
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
function parseYamlFlowList(body) {
|
||||
const items = [];
|
||||
let quote = null;
|
||||
let current = '';
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
const ch = body[i];
|
||||
if ((ch === '"' || ch === "'") && body[i - 1] !== '\\') {
|
||||
quote = quote === ch ? null : quote || ch;
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === ',' && !quote) {
|
||||
const value = unquoteYamlValue(current);
|
||||
if (value) items.push(value);
|
||||
current = '';
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
}
|
||||
const value = unquoteYamlValue(current);
|
||||
if (value) items.push(value);
|
||||
return items;
|
||||
}
|
||||
|
||||
function unquoteYamlValue(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '');
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function projectRootFromWorkspacePattern(repoRoot, relSegments, rawPattern) {
|
||||
const pattern = normalizeWorkspacePattern(rawPattern);
|
||||
if (!pattern || pattern.startsWith('!')) return null;
|
||||
const patternSegments = pattern.split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return null;
|
||||
if (patternSegments.includes('**')) {
|
||||
return projectRootFromDoubleStarPattern(repoRoot, relSegments, patternSegments);
|
||||
}
|
||||
if (relSegments.length < patternSegments.length) return null;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return null;
|
||||
}
|
||||
return path.join(repoRoot, ...relSegments.slice(0, patternSegments.length));
|
||||
}
|
||||
|
||||
function projectRootFromDoubleStarPattern(repoRoot, relSegments, patternSegments) {
|
||||
const firstGlobIndex = patternSegments.findIndex((segment) => segment.includes('*'));
|
||||
const literalPrefix = firstGlobIndex === -1
|
||||
? patternSegments
|
||||
: patternSegments.slice(0, firstGlobIndex);
|
||||
if (relSegments.length < literalPrefix.length + 1) return null;
|
||||
for (let i = 0; i < literalPrefix.length; i++) {
|
||||
if (!segmentMatches(literalPrefix[i], relSegments[i])) return null;
|
||||
}
|
||||
const prefixDir = path.join(repoRoot, ...literalPrefix);
|
||||
const targetDir = path.join(repoRoot, ...relSegments);
|
||||
const packageRoot = nearestPackageRootBetween(repoRoot, targetDir, prefixDir);
|
||||
if (packageRoot) return packageRoot;
|
||||
return path.join(repoRoot, ...relSegments.slice(0, literalPrefix.length + 1));
|
||||
}
|
||||
|
||||
function normalizeWorkspacePattern(pattern) {
|
||||
return String(pattern || '')
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '')
|
||||
.replace(/^\.\//, '')
|
||||
.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function segmentMatches(patternSegment, relSegment) {
|
||||
if (patternSegment === '*') return true;
|
||||
if (!patternSegment.includes('*')) return patternSegment === relSegment;
|
||||
const re = new RegExp(`^${escapeRegExp(patternSegment).replace(/\\\*/g, '[^/]*')}$`);
|
||||
return re.test(relSegment);
|
||||
}
|
||||
|
||||
function firstExisting(dir, names) {
|
||||
for (const name of names) {
|
||||
const abs = path.join(dir, name);
|
||||
@@ -89,6 +664,10 @@ function safeRead(p) {
|
||||
}
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the register (`brand` or `product`) out of PRODUCT.md by looking
|
||||
* for a `## Register` section and reading the first non-empty line that
|
||||
@@ -233,7 +812,24 @@ async function computeUpdateDirective(now = Date.now()) {
|
||||
}
|
||||
|
||||
async function cli() {
|
||||
const ctx = loadContext(process.cwd());
|
||||
let cliOptions;
|
||||
try {
|
||||
cliOptions = parseCliOptions(process.argv.slice(2));
|
||||
} catch (err) {
|
||||
if (err?.name === 'TargetArgError') {
|
||||
process.stderr.write(`${err.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const targetProvided = hasTargetOption(cliOptions);
|
||||
const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null;
|
||||
const selection = resolveTargetSelection(process.cwd(), cliOptions);
|
||||
if (selection) {
|
||||
process.stdout.write(buildTargetSelectionDirective(selection) + '\n');
|
||||
process.exit(0);
|
||||
}
|
||||
const ctx = loadContext(process.cwd(), cliOptions);
|
||||
const updateDirective = await computeUpdateDirective();
|
||||
|
||||
if (!ctx.hasProduct) {
|
||||
@@ -244,6 +840,10 @@ async function cli() {
|
||||
'Stop the current task, load reference/init.md, and follow its ' +
|
||||
'instructions to write PRODUCT.md before resuming.',
|
||||
];
|
||||
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
|
||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||
parts.push(buildMissingTargetDirective());
|
||||
}
|
||||
if (updateDirective) parts.push(updateDirective);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
process.exit(0);
|
||||
@@ -252,6 +852,10 @@ async function cli() {
|
||||
if (ctx.hasDesign) {
|
||||
parts.push(`# DESIGN.md\n\n${ctx.design.trim()}`);
|
||||
}
|
||||
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
|
||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||
parts.push(buildMissingTargetDirective());
|
||||
}
|
||||
const register = extractRegister(ctx.product);
|
||||
const next = register
|
||||
? `NEXT STEP: This project's register is \`${register}\`. You MUST now read \`reference/${register}.md\` before producing any design output.`
|
||||
@@ -261,6 +865,60 @@ async function cli() {
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
}
|
||||
|
||||
function parseCliOptions(args) {
|
||||
return parseTargetOptions(args, { strict: true });
|
||||
}
|
||||
|
||||
function hasTargetOption(options) {
|
||||
return !!(options && typeof options.targetPath === 'string' && options.targetPath.trim());
|
||||
}
|
||||
|
||||
function pathExistsForTarget(cwd, targetPath) {
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
return fs.existsSync(abs);
|
||||
}
|
||||
|
||||
function buildResolvedContextDirective(ctx, options, { targetExists = null } = {}) {
|
||||
const targetPath = hasTargetOption(options) ? options.targetPath : null;
|
||||
return `RESOLVED_CONTEXT:\n${JSON.stringify({
|
||||
targetPath,
|
||||
...(targetPath ? { targetExists } : {}),
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
productPath: ctx.productPath,
|
||||
designPath: ctx.designPath,
|
||||
}, null, 2)}`;
|
||||
}
|
||||
|
||||
function shouldWarnMissingTarget(ctx, targetProvided, targetExists = null) {
|
||||
if (ctx.isMonorepo && targetProvided && targetExists === false) return true;
|
||||
return !!(
|
||||
ctx.isMonorepo
|
||||
&& (!targetProvided || targetExists === false)
|
||||
&& ctx.projectRoot
|
||||
&& ctx.repoRoot
|
||||
&& path.resolve(ctx.projectRoot) === path.resolve(ctx.repoRoot)
|
||||
);
|
||||
}
|
||||
|
||||
function buildMissingTargetDirective() {
|
||||
const script = process.argv[1] || 'context.mjs';
|
||||
return (
|
||||
'MONOREPO_TARGET_REQUIRED: This is a monorepo and context.mjs ran without --target. ' +
|
||||
'If the user named a file, route, or child app, do not answer from this output. ' +
|
||||
`Rerun \`node ${script} --target <path>\` and answer from that run's RESOLVED_CONTEXT fields.`
|
||||
);
|
||||
}
|
||||
|
||||
function buildTargetSelectionDirective(selection) {
|
||||
return (
|
||||
`TARGET_SELECTION_REQUIRED:\n${JSON.stringify(selection, null, 2)}\n\n` +
|
||||
'Show each app with its productStatus/productPath and designStatus/designPath so the user can see child overrides, inherited root files, fallback files, or missing files before choosing. ' +
|
||||
'Ask the user which app Impeccable should use, then rerun Impeccable helper commands from that child app cwd using this same scripts directory. ' +
|
||||
'Use `--target <path>` only as a fallback when changing cwd is not possible, or when the user explicitly named a file/path.'
|
||||
);
|
||||
}
|
||||
|
||||
// Run cli() only when this module is the entry point. Compare realpaths
|
||||
// rather than endsWith(): a loose suffix match also fires for unrelated
|
||||
// scripts like `load-context.mjs`, and realpath tolerates symlinked
|
||||
|
||||
@@ -1,50 +1,52 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { resolveProjectRoot } from '../context.mjs';
|
||||
|
||||
export const IMPECCABLE_DIR = '.impeccable';
|
||||
export const LIVE_DIR = 'live';
|
||||
export const CRITIQUE_DIR = 'critique';
|
||||
|
||||
export function getImpeccableDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, IMPECCABLE_DIR);
|
||||
export function getImpeccableDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), IMPECCABLE_DIR);
|
||||
}
|
||||
|
||||
export function getDesignSidecarPath(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), 'design.json');
|
||||
export function getDesignSidecarPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), 'design.json');
|
||||
}
|
||||
|
||||
export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd) {
|
||||
export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd, options = {}) {
|
||||
const projectRoot = resolveProjectRoot(cwd, options);
|
||||
const candidates = [
|
||||
getDesignSidecarPath(cwd),
|
||||
path.join(cwd, 'DESIGN.json'),
|
||||
getDesignSidecarPath(cwd, options),
|
||||
path.join(projectRoot, 'DESIGN.json'),
|
||||
];
|
||||
const contextLegacy = path.join(contextDir, 'DESIGN.json');
|
||||
if (!candidates.includes(contextLegacy)) candidates.push(contextLegacy);
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) {
|
||||
return firstExisting(getDesignSidecarCandidates(cwd, contextDir));
|
||||
export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd, options = {}) {
|
||||
return firstExisting(getDesignSidecarCandidates(cwd, contextDir, options));
|
||||
}
|
||||
|
||||
export function getLiveDir(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), LIVE_DIR);
|
||||
export function getLiveDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), LIVE_DIR);
|
||||
}
|
||||
|
||||
export function getLiveConfigPath(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'config.json');
|
||||
export function getLiveConfigPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'config.json');
|
||||
}
|
||||
|
||||
export function getLegacyLiveConfigPath(scriptsDir) {
|
||||
return path.join(scriptsDir, 'config.json');
|
||||
}
|
||||
|
||||
export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env } = {}) {
|
||||
export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env, targetPath } = {}) {
|
||||
if (env.IMPECCABLE_LIVE_CONFIG && env.IMPECCABLE_LIVE_CONFIG.trim()) {
|
||||
const configured = env.IMPECCABLE_LIVE_CONFIG.trim();
|
||||
return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured);
|
||||
}
|
||||
const primary = getLiveConfigPath(cwd);
|
||||
const primary = getLiveConfigPath(cwd, { targetPath });
|
||||
if (fs.existsSync(primary)) return primary;
|
||||
if (scriptsDir) {
|
||||
const legacy = getLegacyLiveConfigPath(scriptsDir);
|
||||
@@ -53,16 +55,16 @@ export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = p
|
||||
return primary;
|
||||
}
|
||||
|
||||
export function getLiveServerPath(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'server.json');
|
||||
export function getLiveServerPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'server.json');
|
||||
}
|
||||
|
||||
export function getLegacyLiveServerPath(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live.json');
|
||||
export function getLegacyLiveServerPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live.json');
|
||||
}
|
||||
|
||||
export function readLiveServerInfo(cwd = process.cwd()) {
|
||||
for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) {
|
||||
export function readLiveServerInfo(cwd = process.cwd(), options = {}) {
|
||||
for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) {
|
||||
try {
|
||||
const info = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
if (info && typeof info.pid === 'number' && !isLiveServerPidReachable(info.pid)) {
|
||||
@@ -88,37 +90,37 @@ export function isLiveServerPidReachable(pid) {
|
||||
}
|
||||
}
|
||||
|
||||
export function writeLiveServerInfo(cwd = process.cwd(), info) {
|
||||
const filePath = getLiveServerPath(cwd);
|
||||
export function writeLiveServerInfo(cwd = process.cwd(), info, options = {}) {
|
||||
const filePath = getLiveServerPath(cwd, options);
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(info));
|
||||
return filePath;
|
||||
}
|
||||
|
||||
export function removeLiveServerInfo(cwd = process.cwd()) {
|
||||
for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) {
|
||||
export function removeLiveServerInfo(cwd = process.cwd(), options = {}) {
|
||||
for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) {
|
||||
try { fs.unlinkSync(filePath); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
export function getLiveSessionsDir(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'sessions');
|
||||
export function getLiveSessionsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'sessions');
|
||||
}
|
||||
|
||||
export function getLegacyLiveSessionsDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live', 'sessions');
|
||||
export function getLegacyLiveSessionsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'sessions');
|
||||
}
|
||||
|
||||
export function getLiveAnnotationsDir(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'annotations');
|
||||
export function getLiveAnnotationsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'annotations');
|
||||
}
|
||||
|
||||
export function getCritiqueDir(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), CRITIQUE_DIR);
|
||||
export function getCritiqueDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), CRITIQUE_DIR);
|
||||
}
|
||||
|
||||
export function getLegacyLiveAnnotationsDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live', 'annotations');
|
||||
export function getLegacyLiveAnnotationsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'annotations');
|
||||
}
|
||||
|
||||
function firstExisting(paths) {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
class TargetArgError extends Error {
|
||||
constructor(message, code) {
|
||||
super(message);
|
||||
this.name = 'TargetArgError';
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseTargetPath(args = [], { strict = false } = {}) {
|
||||
let targetPath = null;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = String(args[i]);
|
||||
if (arg === '--target' || arg === '-t') {
|
||||
const next = args[i + 1];
|
||||
if (next && !String(next).startsWith('-')) {
|
||||
targetPath = String(next);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (strict) {
|
||||
throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith('--target=')) {
|
||||
const value = arg.slice('--target='.length);
|
||||
if (value) {
|
||||
targetPath = value;
|
||||
continue;
|
||||
}
|
||||
if (strict) {
|
||||
throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING');
|
||||
}
|
||||
}
|
||||
}
|
||||
return targetPath;
|
||||
}
|
||||
|
||||
export function parseTargetOptions(args = [], options = {}) {
|
||||
const targetPath = parseTargetPath(args, options);
|
||||
return targetPath ? { targetPath } : {};
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import path from 'node:path';
|
||||
import net from 'node:net';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseDesignMd } from './lib/design-parser.mjs';
|
||||
import { resolveContextDir } from './context.mjs';
|
||||
import { loadContext } from './context.mjs';
|
||||
import {
|
||||
assembleLiveBrowserScript,
|
||||
assertLiveBrowserScriptParts,
|
||||
@@ -55,7 +55,11 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
// PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
|
||||
// DESIGN sidecar is project-local at .impeccable/design.json, with legacy
|
||||
// DESIGN.json fallback for existing projects.
|
||||
const CONTEXT_DIR = resolveContextDir(process.cwd());
|
||||
const PROJECT_CONTEXT = loadContext(process.cwd());
|
||||
const CONTEXT_DIR = PROJECT_CONTEXT.contextDir;
|
||||
const DESIGN_MD_PATH = PROJECT_CONTEXT.designPath
|
||||
? path.resolve(process.cwd(), PROJECT_CONTEXT.designPath)
|
||||
: null;
|
||||
const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway
|
||||
const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s
|
||||
|
||||
@@ -371,10 +375,7 @@ function hasProjectContext() {
|
||||
// PRODUCT.md carries brand voice / anti-references — that's what determines
|
||||
// whether variants are brand-aware. DESIGN.md (visual tokens) is a separate
|
||||
// concern, surfaced by the design panel's own empty state.
|
||||
try {
|
||||
fs.accessSync(path.join(CONTEXT_DIR, 'PRODUCT.md'), fs.constants.R_OK);
|
||||
return true;
|
||||
} catch { return false; }
|
||||
return !!PROJECT_CONTEXT.hasProduct;
|
||||
}
|
||||
|
||||
function statOrNull(filePath) {
|
||||
@@ -549,8 +550,8 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
const token = url.searchParams.get('token');
|
||||
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
|
||||
|
||||
const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md');
|
||||
const jsonPath = resolveDesignSidecarPath(process.cwd(), CONTEXT_DIR) || getDesignSidecarPath(process.cwd());
|
||||
const mdPath = DESIGN_MD_PATH;
|
||||
const jsonPath = resolveDesignSidecarPath(process.cwd(), PROJECT_CONTEXT.designContextDir || CONTEXT_DIR) || getDesignSidecarPath(process.cwd());
|
||||
const mdStat = statOrNull(mdPath);
|
||||
const jsonStat = statOrNull(jsonPath);
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import path from 'node:path';
|
||||
import { resolveProjectRoot } from './context.mjs';
|
||||
import { parseTargetPath } from './lib/target-args.mjs';
|
||||
|
||||
export function resolveLiveTarget(cwd = process.cwd(), args = []) {
|
||||
const originalCwd = path.resolve(cwd);
|
||||
let targetPath = null;
|
||||
try {
|
||||
targetPath = parseTargetPath(args, { strict: true });
|
||||
} catch (err) {
|
||||
if (err?.name === 'TargetArgError') {
|
||||
process.stderr.write(`${err.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const absoluteTargetPath = targetPath
|
||||
? path.isAbsolute(targetPath) ? targetPath : path.resolve(originalCwd, targetPath)
|
||||
: null;
|
||||
const projectRoot = targetPath
|
||||
? resolveProjectRoot(originalCwd, { targetPath: absoluteTargetPath })
|
||||
: originalCwd;
|
||||
return {
|
||||
originalCwd,
|
||||
projectRoot,
|
||||
targetPath,
|
||||
absoluteTargetPath,
|
||||
targetOptions: absoluteTargetPath ? { targetPath: absoluteTargetPath } : {},
|
||||
};
|
||||
}
|
||||
@@ -21,14 +21,16 @@ import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadContext } from './context.mjs';
|
||||
import { loadContext, resolveTargetSelection } from './context.mjs';
|
||||
import { resolveFiles } from './live-inject.mjs';
|
||||
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
|
||||
import { resolveLiveTarget } from './live-target.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
async function liveCli() {
|
||||
const args = process.argv.slice(2);
|
||||
const liveTarget = resolveLiveTarget(process.cwd(), args);
|
||||
|
||||
if (args.includes('--help') || args.includes('-h')) {
|
||||
console.log(`Usage: node live.mjs
|
||||
@@ -38,37 +40,78 @@ Prepare everything for live variant mode in a single command:
|
||||
- Starts (or reuses) the live server in the background
|
||||
- Injects the browser script tag
|
||||
- Reads PRODUCT.md / DESIGN.md for project context
|
||||
- In monorepos, choose a child app first; --target <path> is the fallback/manual path
|
||||
|
||||
On success, prints a JSON blob with:
|
||||
{ ok, serverPort, serverToken, pageFile, hasContext, context }
|
||||
{ ok, serverPort, serverToken, pageFiles, projectRoot, repoRoot, targetPath, productPath, designPath }
|
||||
|
||||
On target_selection_required, prints:
|
||||
{ ok: false, error: "target_selection_required", targetCandidates }
|
||||
|
||||
On config_missing, prints:
|
||||
{ ok: false, error: "config_missing", configPath, hint }
|
||||
|
||||
The agent should then:
|
||||
1. If config_missing, create the config and re-run this script
|
||||
2. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort)
|
||||
3. Enter the poll loop: node live-poll.mjs`);
|
||||
1. If target_selection_required, ask which app to use and rerun from that child cwd
|
||||
2. If config_missing, create the config and re-run this script
|
||||
3. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort)
|
||||
4. Enter the poll loop: node live-poll.mjs`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const targetSelection = resolveTargetSelection(liveTarget.originalCwd, liveTarget.targetOptions);
|
||||
if (targetSelection) {
|
||||
console.log(JSON.stringify({
|
||||
ok: false,
|
||||
error: 'target_selection_required',
|
||||
...targetSelection,
|
||||
hint: 'Ask the user which app Impeccable should use, then rerun live from that child app cwd. Use --target <path> only as a fallback or explicit path diagnostic.',
|
||||
}, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const ctx = loadContext(liveTarget.originalCwd, liveTarget.targetOptions);
|
||||
const activeCwd = ctx.projectRoot;
|
||||
const outputTargetPath = liveTarget.targetPath || null;
|
||||
|
||||
const missingContext = missingLiveContext(ctx);
|
||||
if (missingContext.length > 0) {
|
||||
console.log(JSON.stringify({
|
||||
ok: false,
|
||||
error: 'context_missing',
|
||||
missing: missingContext,
|
||||
nextCommand: missingContext.includes('PRODUCT.md') ? 'init' : 'document',
|
||||
targetPath: outputTargetPath,
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
productPath: ctx.productPath,
|
||||
designPath: ctx.designPath,
|
||||
}, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 1. Check config (fail fast if missing — no point starting anything else)
|
||||
const checkOut = runScript('live-inject.mjs', ['--check']);
|
||||
const checkOut = runScript('live-inject.mjs', ['--check'], { cwd: activeCwd });
|
||||
const checkResult = safeParse(checkOut);
|
||||
if (!checkResult || !checkResult.ok) {
|
||||
console.log(JSON.stringify(checkResult || { ok: false, error: 'check_failed', raw: checkOut }));
|
||||
console.log(JSON.stringify({
|
||||
...(checkResult || { ok: false, error: 'check_failed', raw: checkOut }),
|
||||
targetPath: outputTargetPath,
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
}));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 2. Start server (or reuse existing)
|
||||
const serverInfo = ensureServerRunning();
|
||||
const serverInfo = ensureServerRunning(activeCwd);
|
||||
if (!serverInfo) {
|
||||
console.log(JSON.stringify({ ok: false, error: 'server_start_failed' }));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 3. Inject the script tag at the current port
|
||||
const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)]);
|
||||
const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)], { cwd: activeCwd });
|
||||
const injectResult = safeParse(injectOut);
|
||||
if (!injectResult || !injectResult.ok) {
|
||||
console.log(JSON.stringify({
|
||||
@@ -80,22 +123,23 @@ The agent should then:
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 4. Load PRODUCT.md + DESIGN.md context.
|
||||
const ctx = loadContext(process.cwd());
|
||||
|
||||
// 5. Compute drift-heal: compare resolved inject targets against the
|
||||
// 4. 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);
|
||||
const resolvedFiles = resolveFiles(activeCwd, checkResult.config);
|
||||
const drift = scanForDrift(activeCwd, resolvedFiles, checkResult.config);
|
||||
|
||||
// 6. Emit everything the agent needs
|
||||
// 5. Emit everything the agent needs
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
serverPort: serverInfo.port,
|
||||
serverToken: serverInfo.token,
|
||||
pageFiles: resolvedFiles,
|
||||
liveConfigPath: checkResult.path,
|
||||
configDrift: drift,
|
||||
targetPath: outputTargetPath,
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
hasProduct: ctx.hasProduct,
|
||||
product: ctx.product,
|
||||
productPath: ctx.productPath,
|
||||
@@ -105,6 +149,13 @@ The agent should then:
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
function missingLiveContext(ctx) {
|
||||
const missing = [];
|
||||
if (!ctx.hasProduct) missing.push('PRODUCT.md');
|
||||
if (!ctx.hasDesign) missing.push('DESIGN.md');
|
||||
return missing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drift-heal scan. Walks the project for HTML files under common
|
||||
* page-source directories (public/, src/, app/, pages/) and reports any
|
||||
@@ -201,11 +252,11 @@ function globToRegex(pattern) {
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function runScript(name, args) {
|
||||
function runScript(name, args, options = {}) {
|
||||
const scriptPath = path.join(__dirname, name);
|
||||
const cmd = `node "${scriptPath}" ${args.map(a => `"${a}"`).join(' ')}`;
|
||||
try {
|
||||
return execSync(cmd, { encoding: 'utf-8', cwd: process.cwd(), timeout: 15_000 });
|
||||
return execSync(cmd, { encoding: 'utf-8', cwd: options.cwd || process.cwd(), timeout: 15_000 });
|
||||
} catch (err) {
|
||||
// execSync throws on non-zero exit; return stdout if any
|
||||
return err.stdout || err.message || '';
|
||||
@@ -219,10 +270,10 @@ function safeParse(out) {
|
||||
/**
|
||||
* Return { pid, port, token } for the running live server, starting one if needed.
|
||||
*/
|
||||
function ensureServerRunning() {
|
||||
function ensureServerRunning(cwd = process.cwd()) {
|
||||
// Try to reuse an existing server
|
||||
try {
|
||||
const existing = readLiveServerInfo(process.cwd())?.info;
|
||||
const existing = readLiveServerInfo(cwd)?.info;
|
||||
if (existing && existing.pid) {
|
||||
try {
|
||||
process.kill(existing.pid, 0); // throws if dead
|
||||
@@ -232,7 +283,7 @@ function ensureServerRunning() {
|
||||
} catch { /* no PID file */ }
|
||||
|
||||
// Start a new server
|
||||
const out = runScript('live-server.mjs', ['--background']);
|
||||
const out = runScript('live-server.mjs', ['--background'], { cwd });
|
||||
return safeParse(out);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ Designs and iterates production-grade frontend interfaces. Real working code, co
|
||||
|
||||
You MUST do these steps before proceeding:
|
||||
|
||||
1. Run `node .cursor/skills/impeccable/scripts/context.mjs` once per session. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task.
|
||||
1. Run `node .cursor/skills/impeccable/scripts/context.mjs` once per session. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .cursor/skills/impeccable/scripts/context.mjs --target <path>` instead. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task.
|
||||
2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read `reference/<command>.md` next. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects.
|
||||
3. Familiarize yourself with any existing design system, conventions, and components in the code. Read at least one project file (CSS / tokens / theme / a representative component or page). **Required even when you've loaded a sub-command reference in step 2.** Don't reinvent the wheel; use what's there when it works, branch out when the UX wins.
|
||||
4. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md.
|
||||
|
||||
@@ -8,7 +8,7 @@ A running dev server with hot module replacement (Vite, Next.js, Bun, etc.), OR
|
||||
|
||||
Execute in order. No step skipped, no step reordered.
|
||||
|
||||
1. `live.mjs`: boot.
|
||||
1. `live.mjs`: boot. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .cursor/skills/impeccable/scripts/live.mjs --target <path>` instead; then run the rest of this live session from the returned `projectRoot`.
|
||||
2. Open the app URL that serves `pageFile` (infer from `package.json`, docs, terminal output, or an open tab). Never use `serverPort`; it's the helper, not the app. **Cursor:** `browser_navigate` to that URL before polling; do not skip. **Other harnesses:** use the available browser tool; if the URL is uncertain, ask the user once.
|
||||
3. Poll loop with the default long timeout (600000 ms). After every event or `--reply`, run `live-poll.mjs` again immediately. Never pass a short `--timeout=`.
|
||||
|
||||
|
||||
@@ -5,11 +5,12 @@
|
||||
* init flow.
|
||||
*
|
||||
* Path resolution (first match wins):
|
||||
* 1. cwd, if PRODUCT.md or DESIGN.md is there
|
||||
* 2. .agents/context/ then docs/
|
||||
* 3. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user
|
||||
* 1. Active project root, if PRODUCT.md or DESIGN.md is there
|
||||
* 2. Active project .agents/context/ then docs/
|
||||
* 3. Monorepo root context, using the same order, as a per-file fallback
|
||||
* 4. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user
|
||||
* escape hatch, only consulted when defaults are empty
|
||||
* 4. cwd as a "nothing found" default
|
||||
* 5. Active project root as a "nothing found" default
|
||||
*
|
||||
* `resolveContextDir()` and `loadContext()` are also exported for the
|
||||
* server-side scripts (live.mjs, live-server.mjs) that need the structured
|
||||
@@ -19,10 +20,25 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseTargetOptions } from './lib/target-args.mjs';
|
||||
|
||||
const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md'];
|
||||
const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md'];
|
||||
const FALLBACK_DIRS = ['.agents/context', 'docs'];
|
||||
const MONOREPO_MARKER_FILES = ['pnpm-workspace.yaml', 'turbo.json', 'nx.json', 'lerna.json'];
|
||||
const MONOREPO_FALLBACK_PROJECT_DIRS = ['apps', 'packages'];
|
||||
const WORKSPACE_DISCOVERY_IGNORED_DIRS = new Set([
|
||||
'node_modules',
|
||||
'.git',
|
||||
'dist',
|
||||
'build',
|
||||
'.next',
|
||||
'.nuxt',
|
||||
'.svelte-kit',
|
||||
'.turbo',
|
||||
'.cache',
|
||||
'coverage',
|
||||
]);
|
||||
|
||||
// ─── Update check ──────────────────────────────────────────────────────────
|
||||
// Piggyback a lightweight skill-version check on the once-per-session boot.
|
||||
@@ -38,41 +54,600 @@ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // throttle the network poll to o
|
||||
const RENOTIFY_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000; // don't re-surface the same version for a week
|
||||
const FETCH_TIMEOUT_MS = 1200;
|
||||
|
||||
export function resolveContextDir(cwd = process.cwd()) {
|
||||
if (firstExisting(cwd, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return cwd;
|
||||
}
|
||||
for (const rel of FALLBACK_DIRS) {
|
||||
const candidate = path.resolve(cwd, rel);
|
||||
if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
const envDir = process.env.IMPECCABLE_CONTEXT_DIR;
|
||||
if (envDir && envDir.trim()) {
|
||||
const trimmed = envDir.trim();
|
||||
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
||||
}
|
||||
return cwd;
|
||||
export function resolveContextDir(cwd = process.cwd(), options = {}) {
|
||||
return resolveContext(cwd, options).contextDir;
|
||||
}
|
||||
|
||||
export function loadContext(cwd = process.cwd()) {
|
||||
const contextDir = resolveContextDir(cwd);
|
||||
const productPath = firstExisting(contextDir, PRODUCT_NAMES);
|
||||
const designPath = firstExisting(contextDir, DESIGN_NAMES);
|
||||
export function loadContext(cwd = process.cwd(), options = {}) {
|
||||
const resolved = resolveContext(cwd, options);
|
||||
const absCwd = path.resolve(cwd);
|
||||
const productPath = resolved.productPath;
|
||||
const designPath = resolved.designPath;
|
||||
const product = productPath ? safeRead(productPath) : null;
|
||||
const design = designPath ? safeRead(designPath) : null;
|
||||
return {
|
||||
hasProduct: !!product,
|
||||
product,
|
||||
productPath: productPath ? path.relative(cwd, productPath) : null,
|
||||
productPath: productPath ? path.relative(absCwd, productPath) : null,
|
||||
hasDesign: !!design,
|
||||
design,
|
||||
designPath: designPath ? path.relative(cwd, designPath) : null,
|
||||
contextDir,
|
||||
designPath: designPath ? path.relative(absCwd, designPath) : null,
|
||||
contextDir: resolved.contextDir,
|
||||
productContextDir: productPath ? path.dirname(productPath) : null,
|
||||
designContextDir: designPath ? path.dirname(designPath) : null,
|
||||
projectRoot: resolved.projectRoot,
|
||||
repoRoot: resolved.repoRoot,
|
||||
isMonorepo: resolved.isMonorepo,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveContext(cwd = process.cwd(), options = {}) {
|
||||
const absCwd = path.resolve(cwd);
|
||||
const project = resolveProject(absCwd, options);
|
||||
const projectContextDir = resolveLocalContextDir(project.projectRoot);
|
||||
const rootContextDir = project.isMonorepo && project.repoRoot !== project.projectRoot
|
||||
? resolveLocalContextDir(project.repoRoot)
|
||||
: null;
|
||||
|
||||
let productPath =
|
||||
(projectContextDir ? firstExisting(projectContextDir, PRODUCT_NAMES) : null)
|
||||
|| (rootContextDir ? firstExisting(rootContextDir, PRODUCT_NAMES) : null);
|
||||
let designPath =
|
||||
(projectContextDir ? firstExisting(projectContextDir, DESIGN_NAMES) : null)
|
||||
|| (rootContextDir ? firstExisting(rootContextDir, DESIGN_NAMES) : null);
|
||||
|
||||
let envContextDir = null;
|
||||
if (!productPath && !designPath) {
|
||||
envContextDir = resolveEnvContextDir(absCwd);
|
||||
if (envContextDir) {
|
||||
productPath = firstExisting(envContextDir, PRODUCT_NAMES);
|
||||
designPath = firstExisting(envContextDir, DESIGN_NAMES);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
contextDir: productPath
|
||||
? path.dirname(productPath)
|
||||
: designPath
|
||||
? path.dirname(designPath)
|
||||
: envContextDir || project.projectRoot,
|
||||
productPath,
|
||||
designPath,
|
||||
projectRoot: project.projectRoot,
|
||||
repoRoot: project.repoRoot,
|
||||
isMonorepo: project.isMonorepo,
|
||||
targetDir: project.targetDir,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveProjectRoot(cwd = process.cwd(), options = {}) {
|
||||
return resolveProject(cwd, options).projectRoot;
|
||||
}
|
||||
|
||||
export function resolveTargetSelection(cwd = process.cwd(), options = {}) {
|
||||
if (hasTargetOption(options)) return null;
|
||||
const project = resolveProject(cwd);
|
||||
if (
|
||||
!project.isMonorepo
|
||||
|| !project.projectRoot
|
||||
|| !project.repoRoot
|
||||
|| path.resolve(project.projectRoot) !== path.resolve(project.repoRoot)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
targetPath: null,
|
||||
projectRoot: project.projectRoot,
|
||||
repoRoot: project.repoRoot,
|
||||
targetCandidates: discoverTargetCandidates(project.repoRoot),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveProject(cwd = process.cwd(), options = {}) {
|
||||
const absCwd = path.resolve(cwd);
|
||||
const targetDir = resolveTargetDir(absCwd, options);
|
||||
let repoRoot = findMonorepoRoot(targetDir);
|
||||
if (!repoRoot && targetDir !== absCwd) {
|
||||
const cwdRepoRoot = findMonorepoRoot(absCwd);
|
||||
if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {
|
||||
repoRoot = cwdRepoRoot;
|
||||
}
|
||||
}
|
||||
if (!repoRoot) {
|
||||
return {
|
||||
targetDir,
|
||||
projectRoot: absCwd,
|
||||
repoRoot: absCwd,
|
||||
isMonorepo: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
targetDir,
|
||||
projectRoot: resolveWorkspaceProjectRoot(repoRoot, targetDir) || repoRoot,
|
||||
repoRoot,
|
||||
isMonorepo: true,
|
||||
};
|
||||
}
|
||||
|
||||
function isPathInside(candidate, root) {
|
||||
const rel = path.relative(root, candidate);
|
||||
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
|
||||
}
|
||||
|
||||
function resolveLocalContextDir(root) {
|
||||
if (firstExisting(root, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return root;
|
||||
}
|
||||
for (const rel of FALLBACK_DIRS) {
|
||||
const candidate = path.resolve(root, rel);
|
||||
if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveEnvContextDir(cwd) {
|
||||
const envDir = process.env.IMPECCABLE_CONTEXT_DIR;
|
||||
if (!envDir || !envDir.trim()) return null;
|
||||
const trimmed = envDir.trim();
|
||||
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
||||
}
|
||||
|
||||
function resolveTargetDir(cwd, options = {}) {
|
||||
const targetPath = options && typeof options === 'object' ? options.targetPath : null;
|
||||
if (!targetPath || !String(targetPath).trim()) return cwd;
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
try {
|
||||
const stat = fs.statSync(abs);
|
||||
return stat.isDirectory() ? abs : path.dirname(abs);
|
||||
} catch {
|
||||
return path.extname(abs) ? path.dirname(abs) : abs;
|
||||
}
|
||||
}
|
||||
|
||||
function findMonorepoRoot(startDir) {
|
||||
let dir = path.resolve(startDir);
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
while (true) {
|
||||
if (dir === homeDir) return null;
|
||||
if (isMonorepoRoot(dir)) return dir;
|
||||
if (hasGitBoundary(dir)) return null;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function isMonorepoRoot(dir) {
|
||||
if (readWorkspacePatterns(dir).some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) return true;
|
||||
if (!MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(dir, file)))) return false;
|
||||
return hasFallbackWorkspaceChildren(dir);
|
||||
}
|
||||
|
||||
function hasGitBoundary(dir) {
|
||||
return fs.existsSync(path.join(dir, '.git'));
|
||||
}
|
||||
|
||||
function hasFallbackWorkspaceChildren(dir) {
|
||||
for (const name of MONOREPO_FALLBACK_PROJECT_DIRS) {
|
||||
const base = path.join(dir, name);
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(base, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (entries.some((entry) => entry.isDirectory() && !isIgnoredWorkspaceDiscoveryDir(entry.name))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function discoverTargetCandidates(repoRoot) {
|
||||
const roots = new Map();
|
||||
for (const pattern of readWorkspacePatterns(repoRoot)) {
|
||||
for (const root of discoverRootsForPattern(repoRoot, pattern)) {
|
||||
roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root);
|
||||
}
|
||||
}
|
||||
if (MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(repoRoot, file)))) {
|
||||
for (const name of MONOREPO_FALLBACK_PROJECT_DIRS) {
|
||||
const base = path.join(repoRoot, name);
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(base, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
||||
const root = path.join(base, entry.name);
|
||||
roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...roots.entries()]
|
||||
.filter(([rel]) => rel && !rel.startsWith('..'))
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([rel, root]) => {
|
||||
const targetExample = findTargetExample(repoRoot, root);
|
||||
return {
|
||||
name: path.basename(root),
|
||||
path: rel,
|
||||
targetExample,
|
||||
...resolveCandidateContextSummary(repoRoot, root, targetExample),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function resolveCandidateContextSummary(repoRoot, projectRoot, targetPath) {
|
||||
const ctx = resolveContext(repoRoot, { targetPath });
|
||||
return {
|
||||
productStatus: contextSourceStatus(ctx.productPath, repoRoot, projectRoot),
|
||||
productPath: contextSourcePath(ctx.productPath, repoRoot),
|
||||
designStatus: contextSourceStatus(ctx.designPath, repoRoot, projectRoot),
|
||||
designPath: contextSourcePath(ctx.designPath, repoRoot),
|
||||
};
|
||||
}
|
||||
|
||||
function contextSourceStatus(filePath, repoRoot, projectRoot) {
|
||||
if (!filePath) return 'missing';
|
||||
const absPath = path.resolve(filePath);
|
||||
const absProjectRoot = path.resolve(projectRoot);
|
||||
const absRepoRoot = path.resolve(repoRoot);
|
||||
if (isPathInsideOrEqual(absPath, absProjectRoot)) {
|
||||
return path.dirname(absPath) === absProjectRoot ? 'child' : 'fallback';
|
||||
}
|
||||
if (absProjectRoot !== absRepoRoot && isPathInsideOrEqual(absPath, absRepoRoot)) {
|
||||
return 'inherited';
|
||||
}
|
||||
return 'fallback';
|
||||
}
|
||||
|
||||
function contextSourcePath(filePath, repoRoot) {
|
||||
if (!filePath) return null;
|
||||
const rel = path.relative(repoRoot, filePath);
|
||||
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {
|
||||
return rel.split(path.sep).join('/');
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function discoverRootsForPattern(repoRoot, rawPattern) {
|
||||
const pattern = normalizeWorkspacePattern(rawPattern);
|
||||
if (!pattern || pattern.startsWith('!')) return [];
|
||||
const segments = pattern.split('/').filter(Boolean);
|
||||
if (!segments.length) return [];
|
||||
const firstGlobIndex = segments.findIndex((segment) => segment.includes('*'));
|
||||
const literalPrefix = firstGlobIndex === -1 ? segments : segments.slice(0, firstGlobIndex);
|
||||
const base = path.join(repoRoot, ...literalPrefix);
|
||||
if (!fs.existsSync(base)) return [];
|
||||
if (segments.includes('**')) {
|
||||
const packageRoots = [];
|
||||
walkDirs(base, (dir) => {
|
||||
if (dir !== base && isCandidateProjectRoot(dir)) packageRoots.push(dir);
|
||||
});
|
||||
if (packageRoots.length) return packageRoots;
|
||||
return directChildDirs(base);
|
||||
}
|
||||
return expandSimplePattern(repoRoot, segments);
|
||||
}
|
||||
|
||||
function expandSimplePattern(repoRoot, patternSegments, index = 0, current = repoRoot) {
|
||||
if (index >= patternSegments.length) return fs.existsSync(current) ? [current] : [];
|
||||
const segment = patternSegments[index];
|
||||
if (!segment.includes('*')) {
|
||||
return expandSimplePattern(repoRoot, patternSegments, index + 1, path.join(current, segment));
|
||||
}
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(current, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const roots = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
||||
if (!segmentMatches(segment, entry.name)) continue;
|
||||
roots.push(...expandSimplePattern(repoRoot, patternSegments, index + 1, path.join(current, entry.name)));
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
function directChildDirs(dir) {
|
||||
try {
|
||||
return fs.readdirSync(dir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() && !isIgnoredWorkspaceDiscoveryDir(entry.name))
|
||||
.map((entry) => path.join(dir, entry.name));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function walkDirs(root, visit) {
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(root, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
||||
const dir = path.join(root, entry.name);
|
||||
visit(dir);
|
||||
walkDirs(dir, visit);
|
||||
}
|
||||
}
|
||||
|
||||
function isCandidateProjectRoot(dir) {
|
||||
return !!(
|
||||
fs.existsSync(path.join(dir, 'package.json'))
|
||||
|| firstExisting(dir, [...PRODUCT_NAMES, ...DESIGN_NAMES])
|
||||
|| fs.existsSync(path.join(dir, 'src'))
|
||||
|| fs.existsSync(path.join(dir, 'app'))
|
||||
|| fs.existsSync(path.join(dir, 'pages'))
|
||||
|| fs.existsSync(path.join(dir, 'public'))
|
||||
);
|
||||
}
|
||||
|
||||
function isIgnoredWorkspaceDiscoveryDir(name) {
|
||||
return name.startsWith('.') || WORKSPACE_DISCOVERY_IGNORED_DIRS.has(name);
|
||||
}
|
||||
|
||||
function findTargetExample(repoRoot, projectRoot) {
|
||||
const examples = [
|
||||
'src/App.jsx',
|
||||
'src/App.tsx',
|
||||
'src/main.jsx',
|
||||
'src/main.tsx',
|
||||
'src/index.jsx',
|
||||
'src/index.ts',
|
||||
'app/page.tsx',
|
||||
'pages/index.tsx',
|
||||
'public/index.html',
|
||||
];
|
||||
for (const rel of examples) {
|
||||
const abs = path.join(projectRoot, rel);
|
||||
if (fs.existsSync(abs)) return path.relative(repoRoot, abs).split(path.sep).join('/');
|
||||
}
|
||||
return path.relative(repoRoot, projectRoot).split(path.sep).join('/');
|
||||
}
|
||||
|
||||
function resolveWorkspaceProjectRoot(repoRoot, targetDir) {
|
||||
const rel = path.relative(repoRoot, targetDir);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return repoRoot;
|
||||
const relSegments = rel.split(path.sep).filter(Boolean);
|
||||
const patterns = readWorkspacePatterns(repoRoot);
|
||||
const excluded = isExcludedByWorkspacePattern(relSegments, patterns);
|
||||
if (!excluded) {
|
||||
for (const pattern of patterns) {
|
||||
const projectRoot = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern);
|
||||
if (projectRoot) return projectRoot;
|
||||
}
|
||||
}
|
||||
if (excluded) return repoRoot;
|
||||
if (
|
||||
relSegments.length >= 2
|
||||
&& MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0])
|
||||
) {
|
||||
return path.join(repoRoot, relSegments[0], relSegments[1]);
|
||||
}
|
||||
const nearest = nearestProjectLikeRoot(repoRoot, targetDir);
|
||||
if (nearest) return nearest;
|
||||
return repoRoot;
|
||||
}
|
||||
|
||||
function isExcludedByWorkspacePattern(relSegments, patterns) {
|
||||
return patterns.some((rawPattern) => {
|
||||
const pattern = normalizeWorkspacePattern(rawPattern);
|
||||
if (!pattern.startsWith('!')) return false;
|
||||
return workspacePatternMatchesRel(pattern.slice(1), relSegments);
|
||||
});
|
||||
}
|
||||
|
||||
function nearestProjectLikeRoot(repoRoot, targetDir) {
|
||||
let dir = path.resolve(targetDir);
|
||||
const stop = path.resolve(repoRoot);
|
||||
while (dir && dir !== stop) {
|
||||
if (
|
||||
firstExisting(dir, [...PRODUCT_NAMES, ...DESIGN_NAMES])
|
||||
|| fs.existsSync(path.join(dir, 'package.json'))
|
||||
) {
|
||||
return dir;
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function nearestPackageRootBetween(repoRoot, targetDir, stopDir) {
|
||||
let dir = path.resolve(targetDir);
|
||||
const stop = path.resolve(stopDir || repoRoot);
|
||||
const root = path.resolve(repoRoot);
|
||||
while (dir && dir !== stop && isPathInsideOrEqual(dir, root)) {
|
||||
if (fs.existsSync(path.join(dir, 'package.json'))) return dir;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPathInsideOrEqual(candidate, root) {
|
||||
return path.resolve(candidate) === path.resolve(root) || isPathInside(candidate, root);
|
||||
}
|
||||
|
||||
function workspacePatternMatchesRel(pattern, relSegments) {
|
||||
const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return false;
|
||||
if (patternSegments.includes('**')) {
|
||||
const firstGlobIndex = patternSegments.findIndex((segment) => segment.includes('*'));
|
||||
const literalPrefix = firstGlobIndex === -1
|
||||
? patternSegments
|
||||
: patternSegments.slice(0, firstGlobIndex);
|
||||
if (relSegments.length < literalPrefix.length + 1) return false;
|
||||
for (let i = 0; i < literalPrefix.length; i++) {
|
||||
if (!segmentMatches(literalPrefix[i], relSegments[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (relSegments.length < patternSegments.length) return false;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function readWorkspacePatterns(repoRoot) {
|
||||
return [
|
||||
...readPackageWorkspaces(repoRoot),
|
||||
...readPnpmWorkspaces(repoRoot),
|
||||
...readLernaWorkspaces(repoRoot),
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
function readPackageWorkspaces(repoRoot) {
|
||||
const pkg = readJson(path.join(repoRoot, 'package.json'));
|
||||
const workspaces = pkg?.workspaces;
|
||||
if (Array.isArray(workspaces)) return workspaces;
|
||||
if (Array.isArray(workspaces?.packages)) return workspaces.packages;
|
||||
return [];
|
||||
}
|
||||
|
||||
function readLernaWorkspaces(repoRoot) {
|
||||
const lerna = readJson(path.join(repoRoot, 'lerna.json'));
|
||||
return Array.isArray(lerna?.packages) ? lerna.packages : [];
|
||||
}
|
||||
|
||||
function readPnpmWorkspaces(repoRoot) {
|
||||
try {
|
||||
const body = fs.readFileSync(path.join(repoRoot, 'pnpm-workspace.yaml'), 'utf-8');
|
||||
const patterns = [];
|
||||
let inPackages = false;
|
||||
for (const line of body.split(/\r?\n/)) {
|
||||
const trimmed = stripYamlInlineComment(line).trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const flowMatch = trimmed.match(/^packages:\s*\[(.*)\]\s*$/);
|
||||
if (flowMatch) {
|
||||
patterns.push(...parseYamlFlowList(flowMatch[1]));
|
||||
inPackages = false;
|
||||
continue;
|
||||
}
|
||||
if (/^packages:\s*$/.test(trimmed)) {
|
||||
inPackages = true;
|
||||
continue;
|
||||
}
|
||||
if (inPackages && /^[A-Za-z0-9_-]+:\s*/.test(trimmed)) break;
|
||||
if (inPackages) {
|
||||
const match = trimmed.match(/^-\s*(.+)$/);
|
||||
if (match) patterns.push(unquoteYamlValue(match[1]));
|
||||
}
|
||||
}
|
||||
return patterns;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function stripYamlInlineComment(line) {
|
||||
let quote = null;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
if ((ch === '"' || ch === "'") && line[i - 1] !== '\\') {
|
||||
quote = quote === ch ? null : quote || ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === '#' && !quote) return line.slice(0, i);
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
function parseYamlFlowList(body) {
|
||||
const items = [];
|
||||
let quote = null;
|
||||
let current = '';
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
const ch = body[i];
|
||||
if ((ch === '"' || ch === "'") && body[i - 1] !== '\\') {
|
||||
quote = quote === ch ? null : quote || ch;
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === ',' && !quote) {
|
||||
const value = unquoteYamlValue(current);
|
||||
if (value) items.push(value);
|
||||
current = '';
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
}
|
||||
const value = unquoteYamlValue(current);
|
||||
if (value) items.push(value);
|
||||
return items;
|
||||
}
|
||||
|
||||
function unquoteYamlValue(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '');
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function projectRootFromWorkspacePattern(repoRoot, relSegments, rawPattern) {
|
||||
const pattern = normalizeWorkspacePattern(rawPattern);
|
||||
if (!pattern || pattern.startsWith('!')) return null;
|
||||
const patternSegments = pattern.split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return null;
|
||||
if (patternSegments.includes('**')) {
|
||||
return projectRootFromDoubleStarPattern(repoRoot, relSegments, patternSegments);
|
||||
}
|
||||
if (relSegments.length < patternSegments.length) return null;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return null;
|
||||
}
|
||||
return path.join(repoRoot, ...relSegments.slice(0, patternSegments.length));
|
||||
}
|
||||
|
||||
function projectRootFromDoubleStarPattern(repoRoot, relSegments, patternSegments) {
|
||||
const firstGlobIndex = patternSegments.findIndex((segment) => segment.includes('*'));
|
||||
const literalPrefix = firstGlobIndex === -1
|
||||
? patternSegments
|
||||
: patternSegments.slice(0, firstGlobIndex);
|
||||
if (relSegments.length < literalPrefix.length + 1) return null;
|
||||
for (let i = 0; i < literalPrefix.length; i++) {
|
||||
if (!segmentMatches(literalPrefix[i], relSegments[i])) return null;
|
||||
}
|
||||
const prefixDir = path.join(repoRoot, ...literalPrefix);
|
||||
const targetDir = path.join(repoRoot, ...relSegments);
|
||||
const packageRoot = nearestPackageRootBetween(repoRoot, targetDir, prefixDir);
|
||||
if (packageRoot) return packageRoot;
|
||||
return path.join(repoRoot, ...relSegments.slice(0, literalPrefix.length + 1));
|
||||
}
|
||||
|
||||
function normalizeWorkspacePattern(pattern) {
|
||||
return String(pattern || '')
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '')
|
||||
.replace(/^\.\//, '')
|
||||
.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function segmentMatches(patternSegment, relSegment) {
|
||||
if (patternSegment === '*') return true;
|
||||
if (!patternSegment.includes('*')) return patternSegment === relSegment;
|
||||
const re = new RegExp(`^${escapeRegExp(patternSegment).replace(/\\\*/g, '[^/]*')}$`);
|
||||
return re.test(relSegment);
|
||||
}
|
||||
|
||||
function firstExisting(dir, names) {
|
||||
for (const name of names) {
|
||||
const abs = path.join(dir, name);
|
||||
@@ -89,6 +664,10 @@ function safeRead(p) {
|
||||
}
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the register (`brand` or `product`) out of PRODUCT.md by looking
|
||||
* for a `## Register` section and reading the first non-empty line that
|
||||
@@ -233,7 +812,24 @@ async function computeUpdateDirective(now = Date.now()) {
|
||||
}
|
||||
|
||||
async function cli() {
|
||||
const ctx = loadContext(process.cwd());
|
||||
let cliOptions;
|
||||
try {
|
||||
cliOptions = parseCliOptions(process.argv.slice(2));
|
||||
} catch (err) {
|
||||
if (err?.name === 'TargetArgError') {
|
||||
process.stderr.write(`${err.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const targetProvided = hasTargetOption(cliOptions);
|
||||
const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null;
|
||||
const selection = resolveTargetSelection(process.cwd(), cliOptions);
|
||||
if (selection) {
|
||||
process.stdout.write(buildTargetSelectionDirective(selection) + '\n');
|
||||
process.exit(0);
|
||||
}
|
||||
const ctx = loadContext(process.cwd(), cliOptions);
|
||||
const updateDirective = await computeUpdateDirective();
|
||||
|
||||
if (!ctx.hasProduct) {
|
||||
@@ -244,6 +840,10 @@ async function cli() {
|
||||
'Stop the current task, load reference/init.md, and follow its ' +
|
||||
'instructions to write PRODUCT.md before resuming.',
|
||||
];
|
||||
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
|
||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||
parts.push(buildMissingTargetDirective());
|
||||
}
|
||||
if (updateDirective) parts.push(updateDirective);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
process.exit(0);
|
||||
@@ -252,6 +852,10 @@ async function cli() {
|
||||
if (ctx.hasDesign) {
|
||||
parts.push(`# DESIGN.md\n\n${ctx.design.trim()}`);
|
||||
}
|
||||
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
|
||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||
parts.push(buildMissingTargetDirective());
|
||||
}
|
||||
const register = extractRegister(ctx.product);
|
||||
const next = register
|
||||
? `NEXT STEP: This project's register is \`${register}\`. You MUST now read \`reference/${register}.md\` before producing any design output.`
|
||||
@@ -261,6 +865,60 @@ async function cli() {
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
}
|
||||
|
||||
function parseCliOptions(args) {
|
||||
return parseTargetOptions(args, { strict: true });
|
||||
}
|
||||
|
||||
function hasTargetOption(options) {
|
||||
return !!(options && typeof options.targetPath === 'string' && options.targetPath.trim());
|
||||
}
|
||||
|
||||
function pathExistsForTarget(cwd, targetPath) {
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
return fs.existsSync(abs);
|
||||
}
|
||||
|
||||
function buildResolvedContextDirective(ctx, options, { targetExists = null } = {}) {
|
||||
const targetPath = hasTargetOption(options) ? options.targetPath : null;
|
||||
return `RESOLVED_CONTEXT:\n${JSON.stringify({
|
||||
targetPath,
|
||||
...(targetPath ? { targetExists } : {}),
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
productPath: ctx.productPath,
|
||||
designPath: ctx.designPath,
|
||||
}, null, 2)}`;
|
||||
}
|
||||
|
||||
function shouldWarnMissingTarget(ctx, targetProvided, targetExists = null) {
|
||||
if (ctx.isMonorepo && targetProvided && targetExists === false) return true;
|
||||
return !!(
|
||||
ctx.isMonorepo
|
||||
&& (!targetProvided || targetExists === false)
|
||||
&& ctx.projectRoot
|
||||
&& ctx.repoRoot
|
||||
&& path.resolve(ctx.projectRoot) === path.resolve(ctx.repoRoot)
|
||||
);
|
||||
}
|
||||
|
||||
function buildMissingTargetDirective() {
|
||||
const script = process.argv[1] || 'context.mjs';
|
||||
return (
|
||||
'MONOREPO_TARGET_REQUIRED: This is a monorepo and context.mjs ran without --target. ' +
|
||||
'If the user named a file, route, or child app, do not answer from this output. ' +
|
||||
`Rerun \`node ${script} --target <path>\` and answer from that run's RESOLVED_CONTEXT fields.`
|
||||
);
|
||||
}
|
||||
|
||||
function buildTargetSelectionDirective(selection) {
|
||||
return (
|
||||
`TARGET_SELECTION_REQUIRED:\n${JSON.stringify(selection, null, 2)}\n\n` +
|
||||
'Show each app with its productStatus/productPath and designStatus/designPath so the user can see child overrides, inherited root files, fallback files, or missing files before choosing. ' +
|
||||
'Ask the user which app Impeccable should use, then rerun Impeccable helper commands from that child app cwd using this same scripts directory. ' +
|
||||
'Use `--target <path>` only as a fallback when changing cwd is not possible, or when the user explicitly named a file/path.'
|
||||
);
|
||||
}
|
||||
|
||||
// Run cli() only when this module is the entry point. Compare realpaths
|
||||
// rather than endsWith(): a loose suffix match also fires for unrelated
|
||||
// scripts like `load-context.mjs`, and realpath tolerates symlinked
|
||||
|
||||
@@ -1,50 +1,52 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { resolveProjectRoot } from '../context.mjs';
|
||||
|
||||
export const IMPECCABLE_DIR = '.impeccable';
|
||||
export const LIVE_DIR = 'live';
|
||||
export const CRITIQUE_DIR = 'critique';
|
||||
|
||||
export function getImpeccableDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, IMPECCABLE_DIR);
|
||||
export function getImpeccableDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), IMPECCABLE_DIR);
|
||||
}
|
||||
|
||||
export function getDesignSidecarPath(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), 'design.json');
|
||||
export function getDesignSidecarPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), 'design.json');
|
||||
}
|
||||
|
||||
export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd) {
|
||||
export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd, options = {}) {
|
||||
const projectRoot = resolveProjectRoot(cwd, options);
|
||||
const candidates = [
|
||||
getDesignSidecarPath(cwd),
|
||||
path.join(cwd, 'DESIGN.json'),
|
||||
getDesignSidecarPath(cwd, options),
|
||||
path.join(projectRoot, 'DESIGN.json'),
|
||||
];
|
||||
const contextLegacy = path.join(contextDir, 'DESIGN.json');
|
||||
if (!candidates.includes(contextLegacy)) candidates.push(contextLegacy);
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) {
|
||||
return firstExisting(getDesignSidecarCandidates(cwd, contextDir));
|
||||
export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd, options = {}) {
|
||||
return firstExisting(getDesignSidecarCandidates(cwd, contextDir, options));
|
||||
}
|
||||
|
||||
export function getLiveDir(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), LIVE_DIR);
|
||||
export function getLiveDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), LIVE_DIR);
|
||||
}
|
||||
|
||||
export function getLiveConfigPath(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'config.json');
|
||||
export function getLiveConfigPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'config.json');
|
||||
}
|
||||
|
||||
export function getLegacyLiveConfigPath(scriptsDir) {
|
||||
return path.join(scriptsDir, 'config.json');
|
||||
}
|
||||
|
||||
export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env } = {}) {
|
||||
export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env, targetPath } = {}) {
|
||||
if (env.IMPECCABLE_LIVE_CONFIG && env.IMPECCABLE_LIVE_CONFIG.trim()) {
|
||||
const configured = env.IMPECCABLE_LIVE_CONFIG.trim();
|
||||
return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured);
|
||||
}
|
||||
const primary = getLiveConfigPath(cwd);
|
||||
const primary = getLiveConfigPath(cwd, { targetPath });
|
||||
if (fs.existsSync(primary)) return primary;
|
||||
if (scriptsDir) {
|
||||
const legacy = getLegacyLiveConfigPath(scriptsDir);
|
||||
@@ -53,16 +55,16 @@ export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = p
|
||||
return primary;
|
||||
}
|
||||
|
||||
export function getLiveServerPath(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'server.json');
|
||||
export function getLiveServerPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'server.json');
|
||||
}
|
||||
|
||||
export function getLegacyLiveServerPath(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live.json');
|
||||
export function getLegacyLiveServerPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live.json');
|
||||
}
|
||||
|
||||
export function readLiveServerInfo(cwd = process.cwd()) {
|
||||
for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) {
|
||||
export function readLiveServerInfo(cwd = process.cwd(), options = {}) {
|
||||
for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) {
|
||||
try {
|
||||
const info = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
if (info && typeof info.pid === 'number' && !isLiveServerPidReachable(info.pid)) {
|
||||
@@ -88,37 +90,37 @@ export function isLiveServerPidReachable(pid) {
|
||||
}
|
||||
}
|
||||
|
||||
export function writeLiveServerInfo(cwd = process.cwd(), info) {
|
||||
const filePath = getLiveServerPath(cwd);
|
||||
export function writeLiveServerInfo(cwd = process.cwd(), info, options = {}) {
|
||||
const filePath = getLiveServerPath(cwd, options);
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(info));
|
||||
return filePath;
|
||||
}
|
||||
|
||||
export function removeLiveServerInfo(cwd = process.cwd()) {
|
||||
for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) {
|
||||
export function removeLiveServerInfo(cwd = process.cwd(), options = {}) {
|
||||
for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) {
|
||||
try { fs.unlinkSync(filePath); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
export function getLiveSessionsDir(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'sessions');
|
||||
export function getLiveSessionsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'sessions');
|
||||
}
|
||||
|
||||
export function getLegacyLiveSessionsDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live', 'sessions');
|
||||
export function getLegacyLiveSessionsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'sessions');
|
||||
}
|
||||
|
||||
export function getLiveAnnotationsDir(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'annotations');
|
||||
export function getLiveAnnotationsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'annotations');
|
||||
}
|
||||
|
||||
export function getCritiqueDir(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), CRITIQUE_DIR);
|
||||
export function getCritiqueDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), CRITIQUE_DIR);
|
||||
}
|
||||
|
||||
export function getLegacyLiveAnnotationsDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live', 'annotations');
|
||||
export function getLegacyLiveAnnotationsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'annotations');
|
||||
}
|
||||
|
||||
function firstExisting(paths) {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
class TargetArgError extends Error {
|
||||
constructor(message, code) {
|
||||
super(message);
|
||||
this.name = 'TargetArgError';
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseTargetPath(args = [], { strict = false } = {}) {
|
||||
let targetPath = null;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = String(args[i]);
|
||||
if (arg === '--target' || arg === '-t') {
|
||||
const next = args[i + 1];
|
||||
if (next && !String(next).startsWith('-')) {
|
||||
targetPath = String(next);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (strict) {
|
||||
throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith('--target=')) {
|
||||
const value = arg.slice('--target='.length);
|
||||
if (value) {
|
||||
targetPath = value;
|
||||
continue;
|
||||
}
|
||||
if (strict) {
|
||||
throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING');
|
||||
}
|
||||
}
|
||||
}
|
||||
return targetPath;
|
||||
}
|
||||
|
||||
export function parseTargetOptions(args = [], options = {}) {
|
||||
const targetPath = parseTargetPath(args, options);
|
||||
return targetPath ? { targetPath } : {};
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import path from 'node:path';
|
||||
import net from 'node:net';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseDesignMd } from './lib/design-parser.mjs';
|
||||
import { resolveContextDir } from './context.mjs';
|
||||
import { loadContext } from './context.mjs';
|
||||
import {
|
||||
assembleLiveBrowserScript,
|
||||
assertLiveBrowserScriptParts,
|
||||
@@ -55,7 +55,11 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
// PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
|
||||
// DESIGN sidecar is project-local at .impeccable/design.json, with legacy
|
||||
// DESIGN.json fallback for existing projects.
|
||||
const CONTEXT_DIR = resolveContextDir(process.cwd());
|
||||
const PROJECT_CONTEXT = loadContext(process.cwd());
|
||||
const CONTEXT_DIR = PROJECT_CONTEXT.contextDir;
|
||||
const DESIGN_MD_PATH = PROJECT_CONTEXT.designPath
|
||||
? path.resolve(process.cwd(), PROJECT_CONTEXT.designPath)
|
||||
: null;
|
||||
const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway
|
||||
const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s
|
||||
|
||||
@@ -371,10 +375,7 @@ function hasProjectContext() {
|
||||
// PRODUCT.md carries brand voice / anti-references — that's what determines
|
||||
// whether variants are brand-aware. DESIGN.md (visual tokens) is a separate
|
||||
// concern, surfaced by the design panel's own empty state.
|
||||
try {
|
||||
fs.accessSync(path.join(CONTEXT_DIR, 'PRODUCT.md'), fs.constants.R_OK);
|
||||
return true;
|
||||
} catch { return false; }
|
||||
return !!PROJECT_CONTEXT.hasProduct;
|
||||
}
|
||||
|
||||
function statOrNull(filePath) {
|
||||
@@ -549,8 +550,8 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
const token = url.searchParams.get('token');
|
||||
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
|
||||
|
||||
const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md');
|
||||
const jsonPath = resolveDesignSidecarPath(process.cwd(), CONTEXT_DIR) || getDesignSidecarPath(process.cwd());
|
||||
const mdPath = DESIGN_MD_PATH;
|
||||
const jsonPath = resolveDesignSidecarPath(process.cwd(), PROJECT_CONTEXT.designContextDir || CONTEXT_DIR) || getDesignSidecarPath(process.cwd());
|
||||
const mdStat = statOrNull(mdPath);
|
||||
const jsonStat = statOrNull(jsonPath);
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import path from 'node:path';
|
||||
import { resolveProjectRoot } from './context.mjs';
|
||||
import { parseTargetPath } from './lib/target-args.mjs';
|
||||
|
||||
export function resolveLiveTarget(cwd = process.cwd(), args = []) {
|
||||
const originalCwd = path.resolve(cwd);
|
||||
let targetPath = null;
|
||||
try {
|
||||
targetPath = parseTargetPath(args, { strict: true });
|
||||
} catch (err) {
|
||||
if (err?.name === 'TargetArgError') {
|
||||
process.stderr.write(`${err.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const absoluteTargetPath = targetPath
|
||||
? path.isAbsolute(targetPath) ? targetPath : path.resolve(originalCwd, targetPath)
|
||||
: null;
|
||||
const projectRoot = targetPath
|
||||
? resolveProjectRoot(originalCwd, { targetPath: absoluteTargetPath })
|
||||
: originalCwd;
|
||||
return {
|
||||
originalCwd,
|
||||
projectRoot,
|
||||
targetPath,
|
||||
absoluteTargetPath,
|
||||
targetOptions: absoluteTargetPath ? { targetPath: absoluteTargetPath } : {},
|
||||
};
|
||||
}
|
||||
@@ -21,14 +21,16 @@ import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadContext } from './context.mjs';
|
||||
import { loadContext, resolveTargetSelection } from './context.mjs';
|
||||
import { resolveFiles } from './live-inject.mjs';
|
||||
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
|
||||
import { resolveLiveTarget } from './live-target.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
async function liveCli() {
|
||||
const args = process.argv.slice(2);
|
||||
const liveTarget = resolveLiveTarget(process.cwd(), args);
|
||||
|
||||
if (args.includes('--help') || args.includes('-h')) {
|
||||
console.log(`Usage: node live.mjs
|
||||
@@ -38,37 +40,78 @@ Prepare everything for live variant mode in a single command:
|
||||
- Starts (or reuses) the live server in the background
|
||||
- Injects the browser script tag
|
||||
- Reads PRODUCT.md / DESIGN.md for project context
|
||||
- In monorepos, choose a child app first; --target <path> is the fallback/manual path
|
||||
|
||||
On success, prints a JSON blob with:
|
||||
{ ok, serverPort, serverToken, pageFile, hasContext, context }
|
||||
{ ok, serverPort, serverToken, pageFiles, projectRoot, repoRoot, targetPath, productPath, designPath }
|
||||
|
||||
On target_selection_required, prints:
|
||||
{ ok: false, error: "target_selection_required", targetCandidates }
|
||||
|
||||
On config_missing, prints:
|
||||
{ ok: false, error: "config_missing", configPath, hint }
|
||||
|
||||
The agent should then:
|
||||
1. If config_missing, create the config and re-run this script
|
||||
2. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort)
|
||||
3. Enter the poll loop: node live-poll.mjs`);
|
||||
1. If target_selection_required, ask which app to use and rerun from that child cwd
|
||||
2. If config_missing, create the config and re-run this script
|
||||
3. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort)
|
||||
4. Enter the poll loop: node live-poll.mjs`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const targetSelection = resolveTargetSelection(liveTarget.originalCwd, liveTarget.targetOptions);
|
||||
if (targetSelection) {
|
||||
console.log(JSON.stringify({
|
||||
ok: false,
|
||||
error: 'target_selection_required',
|
||||
...targetSelection,
|
||||
hint: 'Ask the user which app Impeccable should use, then rerun live from that child app cwd. Use --target <path> only as a fallback or explicit path diagnostic.',
|
||||
}, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const ctx = loadContext(liveTarget.originalCwd, liveTarget.targetOptions);
|
||||
const activeCwd = ctx.projectRoot;
|
||||
const outputTargetPath = liveTarget.targetPath || null;
|
||||
|
||||
const missingContext = missingLiveContext(ctx);
|
||||
if (missingContext.length > 0) {
|
||||
console.log(JSON.stringify({
|
||||
ok: false,
|
||||
error: 'context_missing',
|
||||
missing: missingContext,
|
||||
nextCommand: missingContext.includes('PRODUCT.md') ? 'init' : 'document',
|
||||
targetPath: outputTargetPath,
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
productPath: ctx.productPath,
|
||||
designPath: ctx.designPath,
|
||||
}, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 1. Check config (fail fast if missing — no point starting anything else)
|
||||
const checkOut = runScript('live-inject.mjs', ['--check']);
|
||||
const checkOut = runScript('live-inject.mjs', ['--check'], { cwd: activeCwd });
|
||||
const checkResult = safeParse(checkOut);
|
||||
if (!checkResult || !checkResult.ok) {
|
||||
console.log(JSON.stringify(checkResult || { ok: false, error: 'check_failed', raw: checkOut }));
|
||||
console.log(JSON.stringify({
|
||||
...(checkResult || { ok: false, error: 'check_failed', raw: checkOut }),
|
||||
targetPath: outputTargetPath,
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
}));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 2. Start server (or reuse existing)
|
||||
const serverInfo = ensureServerRunning();
|
||||
const serverInfo = ensureServerRunning(activeCwd);
|
||||
if (!serverInfo) {
|
||||
console.log(JSON.stringify({ ok: false, error: 'server_start_failed' }));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 3. Inject the script tag at the current port
|
||||
const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)]);
|
||||
const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)], { cwd: activeCwd });
|
||||
const injectResult = safeParse(injectOut);
|
||||
if (!injectResult || !injectResult.ok) {
|
||||
console.log(JSON.stringify({
|
||||
@@ -80,22 +123,23 @@ The agent should then:
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 4. Load PRODUCT.md + DESIGN.md context.
|
||||
const ctx = loadContext(process.cwd());
|
||||
|
||||
// 5. Compute drift-heal: compare resolved inject targets against the
|
||||
// 4. 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);
|
||||
const resolvedFiles = resolveFiles(activeCwd, checkResult.config);
|
||||
const drift = scanForDrift(activeCwd, resolvedFiles, checkResult.config);
|
||||
|
||||
// 6. Emit everything the agent needs
|
||||
// 5. Emit everything the agent needs
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
serverPort: serverInfo.port,
|
||||
serverToken: serverInfo.token,
|
||||
pageFiles: resolvedFiles,
|
||||
liveConfigPath: checkResult.path,
|
||||
configDrift: drift,
|
||||
targetPath: outputTargetPath,
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
hasProduct: ctx.hasProduct,
|
||||
product: ctx.product,
|
||||
productPath: ctx.productPath,
|
||||
@@ -105,6 +149,13 @@ The agent should then:
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
function missingLiveContext(ctx) {
|
||||
const missing = [];
|
||||
if (!ctx.hasProduct) missing.push('PRODUCT.md');
|
||||
if (!ctx.hasDesign) missing.push('DESIGN.md');
|
||||
return missing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drift-heal scan. Walks the project for HTML files under common
|
||||
* page-source directories (public/, src/, app/, pages/) and reports any
|
||||
@@ -201,11 +252,11 @@ function globToRegex(pattern) {
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function runScript(name, args) {
|
||||
function runScript(name, args, options = {}) {
|
||||
const scriptPath = path.join(__dirname, name);
|
||||
const cmd = `node "${scriptPath}" ${args.map(a => `"${a}"`).join(' ')}`;
|
||||
try {
|
||||
return execSync(cmd, { encoding: 'utf-8', cwd: process.cwd(), timeout: 15_000 });
|
||||
return execSync(cmd, { encoding: 'utf-8', cwd: options.cwd || process.cwd(), timeout: 15_000 });
|
||||
} catch (err) {
|
||||
// execSync throws on non-zero exit; return stdout if any
|
||||
return err.stdout || err.message || '';
|
||||
@@ -219,10 +270,10 @@ function safeParse(out) {
|
||||
/**
|
||||
* Return { pid, port, token } for the running live server, starting one if needed.
|
||||
*/
|
||||
function ensureServerRunning() {
|
||||
function ensureServerRunning(cwd = process.cwd()) {
|
||||
// Try to reuse an existing server
|
||||
try {
|
||||
const existing = readLiveServerInfo(process.cwd())?.info;
|
||||
const existing = readLiveServerInfo(cwd)?.info;
|
||||
if (existing && existing.pid) {
|
||||
try {
|
||||
process.kill(existing.pid, 0); // throws if dead
|
||||
@@ -232,7 +283,7 @@ function ensureServerRunning() {
|
||||
} catch { /* no PID file */ }
|
||||
|
||||
// Start a new server
|
||||
const out = runScript('live-server.mjs', ['--background']);
|
||||
const out = runScript('live-server.mjs', ['--background'], { cwd });
|
||||
return safeParse(out);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ Designs and iterates production-grade frontend interfaces. Real working code, co
|
||||
|
||||
You MUST do these steps before proceeding:
|
||||
|
||||
1. Run `node .gemini/skills/impeccable/scripts/context.mjs` once per session. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task.
|
||||
1. Run `node .gemini/skills/impeccable/scripts/context.mjs` once per session. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .gemini/skills/impeccable/scripts/context.mjs --target <path>` instead. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task.
|
||||
2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read `reference/<command>.md` next. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects.
|
||||
3. Familiarize yourself with any existing design system, conventions, and components in the code. Read at least one project file (CSS / tokens / theme / a representative component or page). **Required even when you've loaded a sub-command reference in step 2.** Don't reinvent the wheel; use what's there when it works, branch out when the UX wins.
|
||||
4. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md.
|
||||
|
||||
@@ -8,7 +8,7 @@ A running dev server with hot module replacement (Vite, Next.js, Bun, etc.), OR
|
||||
|
||||
Execute in order. No step skipped, no step reordered.
|
||||
|
||||
1. `live.mjs`: boot.
|
||||
1. `live.mjs`: boot. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .gemini/skills/impeccable/scripts/live.mjs --target <path>` instead; then run the rest of this live session from the returned `projectRoot`.
|
||||
2. Open the app URL that serves `pageFile` (infer from `package.json`, docs, terminal output, or an open tab). Never use `serverPort`; it's the helper, not the app. **Cursor:** `browser_navigate` to that URL before polling; do not skip. **Other harnesses:** use the available browser tool; if the URL is uncertain, ask the user once.
|
||||
3. Poll loop with the default long timeout (600000 ms). After every event or `--reply`, run `live-poll.mjs` again immediately. Never pass a short `--timeout=`.
|
||||
|
||||
|
||||
@@ -5,11 +5,12 @@
|
||||
* init flow.
|
||||
*
|
||||
* Path resolution (first match wins):
|
||||
* 1. cwd, if PRODUCT.md or DESIGN.md is there
|
||||
* 2. .agents/context/ then docs/
|
||||
* 3. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user
|
||||
* 1. Active project root, if PRODUCT.md or DESIGN.md is there
|
||||
* 2. Active project .agents/context/ then docs/
|
||||
* 3. Monorepo root context, using the same order, as a per-file fallback
|
||||
* 4. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user
|
||||
* escape hatch, only consulted when defaults are empty
|
||||
* 4. cwd as a "nothing found" default
|
||||
* 5. Active project root as a "nothing found" default
|
||||
*
|
||||
* `resolveContextDir()` and `loadContext()` are also exported for the
|
||||
* server-side scripts (live.mjs, live-server.mjs) that need the structured
|
||||
@@ -19,10 +20,25 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseTargetOptions } from './lib/target-args.mjs';
|
||||
|
||||
const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md'];
|
||||
const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md'];
|
||||
const FALLBACK_DIRS = ['.agents/context', 'docs'];
|
||||
const MONOREPO_MARKER_FILES = ['pnpm-workspace.yaml', 'turbo.json', 'nx.json', 'lerna.json'];
|
||||
const MONOREPO_FALLBACK_PROJECT_DIRS = ['apps', 'packages'];
|
||||
const WORKSPACE_DISCOVERY_IGNORED_DIRS = new Set([
|
||||
'node_modules',
|
||||
'.git',
|
||||
'dist',
|
||||
'build',
|
||||
'.next',
|
||||
'.nuxt',
|
||||
'.svelte-kit',
|
||||
'.turbo',
|
||||
'.cache',
|
||||
'coverage',
|
||||
]);
|
||||
|
||||
// ─── Update check ──────────────────────────────────────────────────────────
|
||||
// Piggyback a lightweight skill-version check on the once-per-session boot.
|
||||
@@ -38,41 +54,600 @@ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // throttle the network poll to o
|
||||
const RENOTIFY_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000; // don't re-surface the same version for a week
|
||||
const FETCH_TIMEOUT_MS = 1200;
|
||||
|
||||
export function resolveContextDir(cwd = process.cwd()) {
|
||||
if (firstExisting(cwd, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return cwd;
|
||||
}
|
||||
for (const rel of FALLBACK_DIRS) {
|
||||
const candidate = path.resolve(cwd, rel);
|
||||
if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
const envDir = process.env.IMPECCABLE_CONTEXT_DIR;
|
||||
if (envDir && envDir.trim()) {
|
||||
const trimmed = envDir.trim();
|
||||
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
||||
}
|
||||
return cwd;
|
||||
export function resolveContextDir(cwd = process.cwd(), options = {}) {
|
||||
return resolveContext(cwd, options).contextDir;
|
||||
}
|
||||
|
||||
export function loadContext(cwd = process.cwd()) {
|
||||
const contextDir = resolveContextDir(cwd);
|
||||
const productPath = firstExisting(contextDir, PRODUCT_NAMES);
|
||||
const designPath = firstExisting(contextDir, DESIGN_NAMES);
|
||||
export function loadContext(cwd = process.cwd(), options = {}) {
|
||||
const resolved = resolveContext(cwd, options);
|
||||
const absCwd = path.resolve(cwd);
|
||||
const productPath = resolved.productPath;
|
||||
const designPath = resolved.designPath;
|
||||
const product = productPath ? safeRead(productPath) : null;
|
||||
const design = designPath ? safeRead(designPath) : null;
|
||||
return {
|
||||
hasProduct: !!product,
|
||||
product,
|
||||
productPath: productPath ? path.relative(cwd, productPath) : null,
|
||||
productPath: productPath ? path.relative(absCwd, productPath) : null,
|
||||
hasDesign: !!design,
|
||||
design,
|
||||
designPath: designPath ? path.relative(cwd, designPath) : null,
|
||||
contextDir,
|
||||
designPath: designPath ? path.relative(absCwd, designPath) : null,
|
||||
contextDir: resolved.contextDir,
|
||||
productContextDir: productPath ? path.dirname(productPath) : null,
|
||||
designContextDir: designPath ? path.dirname(designPath) : null,
|
||||
projectRoot: resolved.projectRoot,
|
||||
repoRoot: resolved.repoRoot,
|
||||
isMonorepo: resolved.isMonorepo,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveContext(cwd = process.cwd(), options = {}) {
|
||||
const absCwd = path.resolve(cwd);
|
||||
const project = resolveProject(absCwd, options);
|
||||
const projectContextDir = resolveLocalContextDir(project.projectRoot);
|
||||
const rootContextDir = project.isMonorepo && project.repoRoot !== project.projectRoot
|
||||
? resolveLocalContextDir(project.repoRoot)
|
||||
: null;
|
||||
|
||||
let productPath =
|
||||
(projectContextDir ? firstExisting(projectContextDir, PRODUCT_NAMES) : null)
|
||||
|| (rootContextDir ? firstExisting(rootContextDir, PRODUCT_NAMES) : null);
|
||||
let designPath =
|
||||
(projectContextDir ? firstExisting(projectContextDir, DESIGN_NAMES) : null)
|
||||
|| (rootContextDir ? firstExisting(rootContextDir, DESIGN_NAMES) : null);
|
||||
|
||||
let envContextDir = null;
|
||||
if (!productPath && !designPath) {
|
||||
envContextDir = resolveEnvContextDir(absCwd);
|
||||
if (envContextDir) {
|
||||
productPath = firstExisting(envContextDir, PRODUCT_NAMES);
|
||||
designPath = firstExisting(envContextDir, DESIGN_NAMES);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
contextDir: productPath
|
||||
? path.dirname(productPath)
|
||||
: designPath
|
||||
? path.dirname(designPath)
|
||||
: envContextDir || project.projectRoot,
|
||||
productPath,
|
||||
designPath,
|
||||
projectRoot: project.projectRoot,
|
||||
repoRoot: project.repoRoot,
|
||||
isMonorepo: project.isMonorepo,
|
||||
targetDir: project.targetDir,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveProjectRoot(cwd = process.cwd(), options = {}) {
|
||||
return resolveProject(cwd, options).projectRoot;
|
||||
}
|
||||
|
||||
export function resolveTargetSelection(cwd = process.cwd(), options = {}) {
|
||||
if (hasTargetOption(options)) return null;
|
||||
const project = resolveProject(cwd);
|
||||
if (
|
||||
!project.isMonorepo
|
||||
|| !project.projectRoot
|
||||
|| !project.repoRoot
|
||||
|| path.resolve(project.projectRoot) !== path.resolve(project.repoRoot)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
targetPath: null,
|
||||
projectRoot: project.projectRoot,
|
||||
repoRoot: project.repoRoot,
|
||||
targetCandidates: discoverTargetCandidates(project.repoRoot),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveProject(cwd = process.cwd(), options = {}) {
|
||||
const absCwd = path.resolve(cwd);
|
||||
const targetDir = resolveTargetDir(absCwd, options);
|
||||
let repoRoot = findMonorepoRoot(targetDir);
|
||||
if (!repoRoot && targetDir !== absCwd) {
|
||||
const cwdRepoRoot = findMonorepoRoot(absCwd);
|
||||
if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {
|
||||
repoRoot = cwdRepoRoot;
|
||||
}
|
||||
}
|
||||
if (!repoRoot) {
|
||||
return {
|
||||
targetDir,
|
||||
projectRoot: absCwd,
|
||||
repoRoot: absCwd,
|
||||
isMonorepo: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
targetDir,
|
||||
projectRoot: resolveWorkspaceProjectRoot(repoRoot, targetDir) || repoRoot,
|
||||
repoRoot,
|
||||
isMonorepo: true,
|
||||
};
|
||||
}
|
||||
|
||||
function isPathInside(candidate, root) {
|
||||
const rel = path.relative(root, candidate);
|
||||
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
|
||||
}
|
||||
|
||||
function resolveLocalContextDir(root) {
|
||||
if (firstExisting(root, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return root;
|
||||
}
|
||||
for (const rel of FALLBACK_DIRS) {
|
||||
const candidate = path.resolve(root, rel);
|
||||
if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveEnvContextDir(cwd) {
|
||||
const envDir = process.env.IMPECCABLE_CONTEXT_DIR;
|
||||
if (!envDir || !envDir.trim()) return null;
|
||||
const trimmed = envDir.trim();
|
||||
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
||||
}
|
||||
|
||||
function resolveTargetDir(cwd, options = {}) {
|
||||
const targetPath = options && typeof options === 'object' ? options.targetPath : null;
|
||||
if (!targetPath || !String(targetPath).trim()) return cwd;
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
try {
|
||||
const stat = fs.statSync(abs);
|
||||
return stat.isDirectory() ? abs : path.dirname(abs);
|
||||
} catch {
|
||||
return path.extname(abs) ? path.dirname(abs) : abs;
|
||||
}
|
||||
}
|
||||
|
||||
function findMonorepoRoot(startDir) {
|
||||
let dir = path.resolve(startDir);
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
while (true) {
|
||||
if (dir === homeDir) return null;
|
||||
if (isMonorepoRoot(dir)) return dir;
|
||||
if (hasGitBoundary(dir)) return null;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function isMonorepoRoot(dir) {
|
||||
if (readWorkspacePatterns(dir).some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) return true;
|
||||
if (!MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(dir, file)))) return false;
|
||||
return hasFallbackWorkspaceChildren(dir);
|
||||
}
|
||||
|
||||
function hasGitBoundary(dir) {
|
||||
return fs.existsSync(path.join(dir, '.git'));
|
||||
}
|
||||
|
||||
function hasFallbackWorkspaceChildren(dir) {
|
||||
for (const name of MONOREPO_FALLBACK_PROJECT_DIRS) {
|
||||
const base = path.join(dir, name);
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(base, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (entries.some((entry) => entry.isDirectory() && !isIgnoredWorkspaceDiscoveryDir(entry.name))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function discoverTargetCandidates(repoRoot) {
|
||||
const roots = new Map();
|
||||
for (const pattern of readWorkspacePatterns(repoRoot)) {
|
||||
for (const root of discoverRootsForPattern(repoRoot, pattern)) {
|
||||
roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root);
|
||||
}
|
||||
}
|
||||
if (MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(repoRoot, file)))) {
|
||||
for (const name of MONOREPO_FALLBACK_PROJECT_DIRS) {
|
||||
const base = path.join(repoRoot, name);
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(base, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
||||
const root = path.join(base, entry.name);
|
||||
roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...roots.entries()]
|
||||
.filter(([rel]) => rel && !rel.startsWith('..'))
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([rel, root]) => {
|
||||
const targetExample = findTargetExample(repoRoot, root);
|
||||
return {
|
||||
name: path.basename(root),
|
||||
path: rel,
|
||||
targetExample,
|
||||
...resolveCandidateContextSummary(repoRoot, root, targetExample),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function resolveCandidateContextSummary(repoRoot, projectRoot, targetPath) {
|
||||
const ctx = resolveContext(repoRoot, { targetPath });
|
||||
return {
|
||||
productStatus: contextSourceStatus(ctx.productPath, repoRoot, projectRoot),
|
||||
productPath: contextSourcePath(ctx.productPath, repoRoot),
|
||||
designStatus: contextSourceStatus(ctx.designPath, repoRoot, projectRoot),
|
||||
designPath: contextSourcePath(ctx.designPath, repoRoot),
|
||||
};
|
||||
}
|
||||
|
||||
function contextSourceStatus(filePath, repoRoot, projectRoot) {
|
||||
if (!filePath) return 'missing';
|
||||
const absPath = path.resolve(filePath);
|
||||
const absProjectRoot = path.resolve(projectRoot);
|
||||
const absRepoRoot = path.resolve(repoRoot);
|
||||
if (isPathInsideOrEqual(absPath, absProjectRoot)) {
|
||||
return path.dirname(absPath) === absProjectRoot ? 'child' : 'fallback';
|
||||
}
|
||||
if (absProjectRoot !== absRepoRoot && isPathInsideOrEqual(absPath, absRepoRoot)) {
|
||||
return 'inherited';
|
||||
}
|
||||
return 'fallback';
|
||||
}
|
||||
|
||||
function contextSourcePath(filePath, repoRoot) {
|
||||
if (!filePath) return null;
|
||||
const rel = path.relative(repoRoot, filePath);
|
||||
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {
|
||||
return rel.split(path.sep).join('/');
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function discoverRootsForPattern(repoRoot, rawPattern) {
|
||||
const pattern = normalizeWorkspacePattern(rawPattern);
|
||||
if (!pattern || pattern.startsWith('!')) return [];
|
||||
const segments = pattern.split('/').filter(Boolean);
|
||||
if (!segments.length) return [];
|
||||
const firstGlobIndex = segments.findIndex((segment) => segment.includes('*'));
|
||||
const literalPrefix = firstGlobIndex === -1 ? segments : segments.slice(0, firstGlobIndex);
|
||||
const base = path.join(repoRoot, ...literalPrefix);
|
||||
if (!fs.existsSync(base)) return [];
|
||||
if (segments.includes('**')) {
|
||||
const packageRoots = [];
|
||||
walkDirs(base, (dir) => {
|
||||
if (dir !== base && isCandidateProjectRoot(dir)) packageRoots.push(dir);
|
||||
});
|
||||
if (packageRoots.length) return packageRoots;
|
||||
return directChildDirs(base);
|
||||
}
|
||||
return expandSimplePattern(repoRoot, segments);
|
||||
}
|
||||
|
||||
function expandSimplePattern(repoRoot, patternSegments, index = 0, current = repoRoot) {
|
||||
if (index >= patternSegments.length) return fs.existsSync(current) ? [current] : [];
|
||||
const segment = patternSegments[index];
|
||||
if (!segment.includes('*')) {
|
||||
return expandSimplePattern(repoRoot, patternSegments, index + 1, path.join(current, segment));
|
||||
}
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(current, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const roots = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
||||
if (!segmentMatches(segment, entry.name)) continue;
|
||||
roots.push(...expandSimplePattern(repoRoot, patternSegments, index + 1, path.join(current, entry.name)));
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
function directChildDirs(dir) {
|
||||
try {
|
||||
return fs.readdirSync(dir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() && !isIgnoredWorkspaceDiscoveryDir(entry.name))
|
||||
.map((entry) => path.join(dir, entry.name));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function walkDirs(root, visit) {
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(root, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
||||
const dir = path.join(root, entry.name);
|
||||
visit(dir);
|
||||
walkDirs(dir, visit);
|
||||
}
|
||||
}
|
||||
|
||||
function isCandidateProjectRoot(dir) {
|
||||
return !!(
|
||||
fs.existsSync(path.join(dir, 'package.json'))
|
||||
|| firstExisting(dir, [...PRODUCT_NAMES, ...DESIGN_NAMES])
|
||||
|| fs.existsSync(path.join(dir, 'src'))
|
||||
|| fs.existsSync(path.join(dir, 'app'))
|
||||
|| fs.existsSync(path.join(dir, 'pages'))
|
||||
|| fs.existsSync(path.join(dir, 'public'))
|
||||
);
|
||||
}
|
||||
|
||||
function isIgnoredWorkspaceDiscoveryDir(name) {
|
||||
return name.startsWith('.') || WORKSPACE_DISCOVERY_IGNORED_DIRS.has(name);
|
||||
}
|
||||
|
||||
function findTargetExample(repoRoot, projectRoot) {
|
||||
const examples = [
|
||||
'src/App.jsx',
|
||||
'src/App.tsx',
|
||||
'src/main.jsx',
|
||||
'src/main.tsx',
|
||||
'src/index.jsx',
|
||||
'src/index.ts',
|
||||
'app/page.tsx',
|
||||
'pages/index.tsx',
|
||||
'public/index.html',
|
||||
];
|
||||
for (const rel of examples) {
|
||||
const abs = path.join(projectRoot, rel);
|
||||
if (fs.existsSync(abs)) return path.relative(repoRoot, abs).split(path.sep).join('/');
|
||||
}
|
||||
return path.relative(repoRoot, projectRoot).split(path.sep).join('/');
|
||||
}
|
||||
|
||||
function resolveWorkspaceProjectRoot(repoRoot, targetDir) {
|
||||
const rel = path.relative(repoRoot, targetDir);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return repoRoot;
|
||||
const relSegments = rel.split(path.sep).filter(Boolean);
|
||||
const patterns = readWorkspacePatterns(repoRoot);
|
||||
const excluded = isExcludedByWorkspacePattern(relSegments, patterns);
|
||||
if (!excluded) {
|
||||
for (const pattern of patterns) {
|
||||
const projectRoot = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern);
|
||||
if (projectRoot) return projectRoot;
|
||||
}
|
||||
}
|
||||
if (excluded) return repoRoot;
|
||||
if (
|
||||
relSegments.length >= 2
|
||||
&& MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0])
|
||||
) {
|
||||
return path.join(repoRoot, relSegments[0], relSegments[1]);
|
||||
}
|
||||
const nearest = nearestProjectLikeRoot(repoRoot, targetDir);
|
||||
if (nearest) return nearest;
|
||||
return repoRoot;
|
||||
}
|
||||
|
||||
function isExcludedByWorkspacePattern(relSegments, patterns) {
|
||||
return patterns.some((rawPattern) => {
|
||||
const pattern = normalizeWorkspacePattern(rawPattern);
|
||||
if (!pattern.startsWith('!')) return false;
|
||||
return workspacePatternMatchesRel(pattern.slice(1), relSegments);
|
||||
});
|
||||
}
|
||||
|
||||
function nearestProjectLikeRoot(repoRoot, targetDir) {
|
||||
let dir = path.resolve(targetDir);
|
||||
const stop = path.resolve(repoRoot);
|
||||
while (dir && dir !== stop) {
|
||||
if (
|
||||
firstExisting(dir, [...PRODUCT_NAMES, ...DESIGN_NAMES])
|
||||
|| fs.existsSync(path.join(dir, 'package.json'))
|
||||
) {
|
||||
return dir;
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function nearestPackageRootBetween(repoRoot, targetDir, stopDir) {
|
||||
let dir = path.resolve(targetDir);
|
||||
const stop = path.resolve(stopDir || repoRoot);
|
||||
const root = path.resolve(repoRoot);
|
||||
while (dir && dir !== stop && isPathInsideOrEqual(dir, root)) {
|
||||
if (fs.existsSync(path.join(dir, 'package.json'))) return dir;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPathInsideOrEqual(candidate, root) {
|
||||
return path.resolve(candidate) === path.resolve(root) || isPathInside(candidate, root);
|
||||
}
|
||||
|
||||
function workspacePatternMatchesRel(pattern, relSegments) {
|
||||
const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return false;
|
||||
if (patternSegments.includes('**')) {
|
||||
const firstGlobIndex = patternSegments.findIndex((segment) => segment.includes('*'));
|
||||
const literalPrefix = firstGlobIndex === -1
|
||||
? patternSegments
|
||||
: patternSegments.slice(0, firstGlobIndex);
|
||||
if (relSegments.length < literalPrefix.length + 1) return false;
|
||||
for (let i = 0; i < literalPrefix.length; i++) {
|
||||
if (!segmentMatches(literalPrefix[i], relSegments[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (relSegments.length < patternSegments.length) return false;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function readWorkspacePatterns(repoRoot) {
|
||||
return [
|
||||
...readPackageWorkspaces(repoRoot),
|
||||
...readPnpmWorkspaces(repoRoot),
|
||||
...readLernaWorkspaces(repoRoot),
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
function readPackageWorkspaces(repoRoot) {
|
||||
const pkg = readJson(path.join(repoRoot, 'package.json'));
|
||||
const workspaces = pkg?.workspaces;
|
||||
if (Array.isArray(workspaces)) return workspaces;
|
||||
if (Array.isArray(workspaces?.packages)) return workspaces.packages;
|
||||
return [];
|
||||
}
|
||||
|
||||
function readLernaWorkspaces(repoRoot) {
|
||||
const lerna = readJson(path.join(repoRoot, 'lerna.json'));
|
||||
return Array.isArray(lerna?.packages) ? lerna.packages : [];
|
||||
}
|
||||
|
||||
function readPnpmWorkspaces(repoRoot) {
|
||||
try {
|
||||
const body = fs.readFileSync(path.join(repoRoot, 'pnpm-workspace.yaml'), 'utf-8');
|
||||
const patterns = [];
|
||||
let inPackages = false;
|
||||
for (const line of body.split(/\r?\n/)) {
|
||||
const trimmed = stripYamlInlineComment(line).trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const flowMatch = trimmed.match(/^packages:\s*\[(.*)\]\s*$/);
|
||||
if (flowMatch) {
|
||||
patterns.push(...parseYamlFlowList(flowMatch[1]));
|
||||
inPackages = false;
|
||||
continue;
|
||||
}
|
||||
if (/^packages:\s*$/.test(trimmed)) {
|
||||
inPackages = true;
|
||||
continue;
|
||||
}
|
||||
if (inPackages && /^[A-Za-z0-9_-]+:\s*/.test(trimmed)) break;
|
||||
if (inPackages) {
|
||||
const match = trimmed.match(/^-\s*(.+)$/);
|
||||
if (match) patterns.push(unquoteYamlValue(match[1]));
|
||||
}
|
||||
}
|
||||
return patterns;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function stripYamlInlineComment(line) {
|
||||
let quote = null;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
if ((ch === '"' || ch === "'") && line[i - 1] !== '\\') {
|
||||
quote = quote === ch ? null : quote || ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === '#' && !quote) return line.slice(0, i);
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
function parseYamlFlowList(body) {
|
||||
const items = [];
|
||||
let quote = null;
|
||||
let current = '';
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
const ch = body[i];
|
||||
if ((ch === '"' || ch === "'") && body[i - 1] !== '\\') {
|
||||
quote = quote === ch ? null : quote || ch;
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === ',' && !quote) {
|
||||
const value = unquoteYamlValue(current);
|
||||
if (value) items.push(value);
|
||||
current = '';
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
}
|
||||
const value = unquoteYamlValue(current);
|
||||
if (value) items.push(value);
|
||||
return items;
|
||||
}
|
||||
|
||||
function unquoteYamlValue(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '');
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function projectRootFromWorkspacePattern(repoRoot, relSegments, rawPattern) {
|
||||
const pattern = normalizeWorkspacePattern(rawPattern);
|
||||
if (!pattern || pattern.startsWith('!')) return null;
|
||||
const patternSegments = pattern.split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return null;
|
||||
if (patternSegments.includes('**')) {
|
||||
return projectRootFromDoubleStarPattern(repoRoot, relSegments, patternSegments);
|
||||
}
|
||||
if (relSegments.length < patternSegments.length) return null;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return null;
|
||||
}
|
||||
return path.join(repoRoot, ...relSegments.slice(0, patternSegments.length));
|
||||
}
|
||||
|
||||
function projectRootFromDoubleStarPattern(repoRoot, relSegments, patternSegments) {
|
||||
const firstGlobIndex = patternSegments.findIndex((segment) => segment.includes('*'));
|
||||
const literalPrefix = firstGlobIndex === -1
|
||||
? patternSegments
|
||||
: patternSegments.slice(0, firstGlobIndex);
|
||||
if (relSegments.length < literalPrefix.length + 1) return null;
|
||||
for (let i = 0; i < literalPrefix.length; i++) {
|
||||
if (!segmentMatches(literalPrefix[i], relSegments[i])) return null;
|
||||
}
|
||||
const prefixDir = path.join(repoRoot, ...literalPrefix);
|
||||
const targetDir = path.join(repoRoot, ...relSegments);
|
||||
const packageRoot = nearestPackageRootBetween(repoRoot, targetDir, prefixDir);
|
||||
if (packageRoot) return packageRoot;
|
||||
return path.join(repoRoot, ...relSegments.slice(0, literalPrefix.length + 1));
|
||||
}
|
||||
|
||||
function normalizeWorkspacePattern(pattern) {
|
||||
return String(pattern || '')
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '')
|
||||
.replace(/^\.\//, '')
|
||||
.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function segmentMatches(patternSegment, relSegment) {
|
||||
if (patternSegment === '*') return true;
|
||||
if (!patternSegment.includes('*')) return patternSegment === relSegment;
|
||||
const re = new RegExp(`^${escapeRegExp(patternSegment).replace(/\\\*/g, '[^/]*')}$`);
|
||||
return re.test(relSegment);
|
||||
}
|
||||
|
||||
function firstExisting(dir, names) {
|
||||
for (const name of names) {
|
||||
const abs = path.join(dir, name);
|
||||
@@ -89,6 +664,10 @@ function safeRead(p) {
|
||||
}
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the register (`brand` or `product`) out of PRODUCT.md by looking
|
||||
* for a `## Register` section and reading the first non-empty line that
|
||||
@@ -233,7 +812,24 @@ async function computeUpdateDirective(now = Date.now()) {
|
||||
}
|
||||
|
||||
async function cli() {
|
||||
const ctx = loadContext(process.cwd());
|
||||
let cliOptions;
|
||||
try {
|
||||
cliOptions = parseCliOptions(process.argv.slice(2));
|
||||
} catch (err) {
|
||||
if (err?.name === 'TargetArgError') {
|
||||
process.stderr.write(`${err.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const targetProvided = hasTargetOption(cliOptions);
|
||||
const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null;
|
||||
const selection = resolveTargetSelection(process.cwd(), cliOptions);
|
||||
if (selection) {
|
||||
process.stdout.write(buildTargetSelectionDirective(selection) + '\n');
|
||||
process.exit(0);
|
||||
}
|
||||
const ctx = loadContext(process.cwd(), cliOptions);
|
||||
const updateDirective = await computeUpdateDirective();
|
||||
|
||||
if (!ctx.hasProduct) {
|
||||
@@ -244,6 +840,10 @@ async function cli() {
|
||||
'Stop the current task, load reference/init.md, and follow its ' +
|
||||
'instructions to write PRODUCT.md before resuming.',
|
||||
];
|
||||
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
|
||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||
parts.push(buildMissingTargetDirective());
|
||||
}
|
||||
if (updateDirective) parts.push(updateDirective);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
process.exit(0);
|
||||
@@ -252,6 +852,10 @@ async function cli() {
|
||||
if (ctx.hasDesign) {
|
||||
parts.push(`# DESIGN.md\n\n${ctx.design.trim()}`);
|
||||
}
|
||||
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
|
||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||
parts.push(buildMissingTargetDirective());
|
||||
}
|
||||
const register = extractRegister(ctx.product);
|
||||
const next = register
|
||||
? `NEXT STEP: This project's register is \`${register}\`. You MUST now read \`reference/${register}.md\` before producing any design output.`
|
||||
@@ -261,6 +865,60 @@ async function cli() {
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
}
|
||||
|
||||
function parseCliOptions(args) {
|
||||
return parseTargetOptions(args, { strict: true });
|
||||
}
|
||||
|
||||
function hasTargetOption(options) {
|
||||
return !!(options && typeof options.targetPath === 'string' && options.targetPath.trim());
|
||||
}
|
||||
|
||||
function pathExistsForTarget(cwd, targetPath) {
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
return fs.existsSync(abs);
|
||||
}
|
||||
|
||||
function buildResolvedContextDirective(ctx, options, { targetExists = null } = {}) {
|
||||
const targetPath = hasTargetOption(options) ? options.targetPath : null;
|
||||
return `RESOLVED_CONTEXT:\n${JSON.stringify({
|
||||
targetPath,
|
||||
...(targetPath ? { targetExists } : {}),
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
productPath: ctx.productPath,
|
||||
designPath: ctx.designPath,
|
||||
}, null, 2)}`;
|
||||
}
|
||||
|
||||
function shouldWarnMissingTarget(ctx, targetProvided, targetExists = null) {
|
||||
if (ctx.isMonorepo && targetProvided && targetExists === false) return true;
|
||||
return !!(
|
||||
ctx.isMonorepo
|
||||
&& (!targetProvided || targetExists === false)
|
||||
&& ctx.projectRoot
|
||||
&& ctx.repoRoot
|
||||
&& path.resolve(ctx.projectRoot) === path.resolve(ctx.repoRoot)
|
||||
);
|
||||
}
|
||||
|
||||
function buildMissingTargetDirective() {
|
||||
const script = process.argv[1] || 'context.mjs';
|
||||
return (
|
||||
'MONOREPO_TARGET_REQUIRED: This is a monorepo and context.mjs ran without --target. ' +
|
||||
'If the user named a file, route, or child app, do not answer from this output. ' +
|
||||
`Rerun \`node ${script} --target <path>\` and answer from that run's RESOLVED_CONTEXT fields.`
|
||||
);
|
||||
}
|
||||
|
||||
function buildTargetSelectionDirective(selection) {
|
||||
return (
|
||||
`TARGET_SELECTION_REQUIRED:\n${JSON.stringify(selection, null, 2)}\n\n` +
|
||||
'Show each app with its productStatus/productPath and designStatus/designPath so the user can see child overrides, inherited root files, fallback files, or missing files before choosing. ' +
|
||||
'Ask the user which app Impeccable should use, then rerun Impeccable helper commands from that child app cwd using this same scripts directory. ' +
|
||||
'Use `--target <path>` only as a fallback when changing cwd is not possible, or when the user explicitly named a file/path.'
|
||||
);
|
||||
}
|
||||
|
||||
// Run cli() only when this module is the entry point. Compare realpaths
|
||||
// rather than endsWith(): a loose suffix match also fires for unrelated
|
||||
// scripts like `load-context.mjs`, and realpath tolerates symlinked
|
||||
|
||||
@@ -1,50 +1,52 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { resolveProjectRoot } from '../context.mjs';
|
||||
|
||||
export const IMPECCABLE_DIR = '.impeccable';
|
||||
export const LIVE_DIR = 'live';
|
||||
export const CRITIQUE_DIR = 'critique';
|
||||
|
||||
export function getImpeccableDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, IMPECCABLE_DIR);
|
||||
export function getImpeccableDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), IMPECCABLE_DIR);
|
||||
}
|
||||
|
||||
export function getDesignSidecarPath(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), 'design.json');
|
||||
export function getDesignSidecarPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), 'design.json');
|
||||
}
|
||||
|
||||
export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd) {
|
||||
export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd, options = {}) {
|
||||
const projectRoot = resolveProjectRoot(cwd, options);
|
||||
const candidates = [
|
||||
getDesignSidecarPath(cwd),
|
||||
path.join(cwd, 'DESIGN.json'),
|
||||
getDesignSidecarPath(cwd, options),
|
||||
path.join(projectRoot, 'DESIGN.json'),
|
||||
];
|
||||
const contextLegacy = path.join(contextDir, 'DESIGN.json');
|
||||
if (!candidates.includes(contextLegacy)) candidates.push(contextLegacy);
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) {
|
||||
return firstExisting(getDesignSidecarCandidates(cwd, contextDir));
|
||||
export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd, options = {}) {
|
||||
return firstExisting(getDesignSidecarCandidates(cwd, contextDir, options));
|
||||
}
|
||||
|
||||
export function getLiveDir(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), LIVE_DIR);
|
||||
export function getLiveDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), LIVE_DIR);
|
||||
}
|
||||
|
||||
export function getLiveConfigPath(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'config.json');
|
||||
export function getLiveConfigPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'config.json');
|
||||
}
|
||||
|
||||
export function getLegacyLiveConfigPath(scriptsDir) {
|
||||
return path.join(scriptsDir, 'config.json');
|
||||
}
|
||||
|
||||
export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env } = {}) {
|
||||
export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env, targetPath } = {}) {
|
||||
if (env.IMPECCABLE_LIVE_CONFIG && env.IMPECCABLE_LIVE_CONFIG.trim()) {
|
||||
const configured = env.IMPECCABLE_LIVE_CONFIG.trim();
|
||||
return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured);
|
||||
}
|
||||
const primary = getLiveConfigPath(cwd);
|
||||
const primary = getLiveConfigPath(cwd, { targetPath });
|
||||
if (fs.existsSync(primary)) return primary;
|
||||
if (scriptsDir) {
|
||||
const legacy = getLegacyLiveConfigPath(scriptsDir);
|
||||
@@ -53,16 +55,16 @@ export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = p
|
||||
return primary;
|
||||
}
|
||||
|
||||
export function getLiveServerPath(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'server.json');
|
||||
export function getLiveServerPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'server.json');
|
||||
}
|
||||
|
||||
export function getLegacyLiveServerPath(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live.json');
|
||||
export function getLegacyLiveServerPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live.json');
|
||||
}
|
||||
|
||||
export function readLiveServerInfo(cwd = process.cwd()) {
|
||||
for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) {
|
||||
export function readLiveServerInfo(cwd = process.cwd(), options = {}) {
|
||||
for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) {
|
||||
try {
|
||||
const info = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
if (info && typeof info.pid === 'number' && !isLiveServerPidReachable(info.pid)) {
|
||||
@@ -88,37 +90,37 @@ export function isLiveServerPidReachable(pid) {
|
||||
}
|
||||
}
|
||||
|
||||
export function writeLiveServerInfo(cwd = process.cwd(), info) {
|
||||
const filePath = getLiveServerPath(cwd);
|
||||
export function writeLiveServerInfo(cwd = process.cwd(), info, options = {}) {
|
||||
const filePath = getLiveServerPath(cwd, options);
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(info));
|
||||
return filePath;
|
||||
}
|
||||
|
||||
export function removeLiveServerInfo(cwd = process.cwd()) {
|
||||
for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) {
|
||||
export function removeLiveServerInfo(cwd = process.cwd(), options = {}) {
|
||||
for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) {
|
||||
try { fs.unlinkSync(filePath); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
export function getLiveSessionsDir(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'sessions');
|
||||
export function getLiveSessionsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'sessions');
|
||||
}
|
||||
|
||||
export function getLegacyLiveSessionsDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live', 'sessions');
|
||||
export function getLegacyLiveSessionsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'sessions');
|
||||
}
|
||||
|
||||
export function getLiveAnnotationsDir(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'annotations');
|
||||
export function getLiveAnnotationsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'annotations');
|
||||
}
|
||||
|
||||
export function getCritiqueDir(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), CRITIQUE_DIR);
|
||||
export function getCritiqueDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), CRITIQUE_DIR);
|
||||
}
|
||||
|
||||
export function getLegacyLiveAnnotationsDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live', 'annotations');
|
||||
export function getLegacyLiveAnnotationsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'annotations');
|
||||
}
|
||||
|
||||
function firstExisting(paths) {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
class TargetArgError extends Error {
|
||||
constructor(message, code) {
|
||||
super(message);
|
||||
this.name = 'TargetArgError';
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseTargetPath(args = [], { strict = false } = {}) {
|
||||
let targetPath = null;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = String(args[i]);
|
||||
if (arg === '--target' || arg === '-t') {
|
||||
const next = args[i + 1];
|
||||
if (next && !String(next).startsWith('-')) {
|
||||
targetPath = String(next);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (strict) {
|
||||
throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith('--target=')) {
|
||||
const value = arg.slice('--target='.length);
|
||||
if (value) {
|
||||
targetPath = value;
|
||||
continue;
|
||||
}
|
||||
if (strict) {
|
||||
throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING');
|
||||
}
|
||||
}
|
||||
}
|
||||
return targetPath;
|
||||
}
|
||||
|
||||
export function parseTargetOptions(args = [], options = {}) {
|
||||
const targetPath = parseTargetPath(args, options);
|
||||
return targetPath ? { targetPath } : {};
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import path from 'node:path';
|
||||
import net from 'node:net';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseDesignMd } from './lib/design-parser.mjs';
|
||||
import { resolveContextDir } from './context.mjs';
|
||||
import { loadContext } from './context.mjs';
|
||||
import {
|
||||
assembleLiveBrowserScript,
|
||||
assertLiveBrowserScriptParts,
|
||||
@@ -55,7 +55,11 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
// PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
|
||||
// DESIGN sidecar is project-local at .impeccable/design.json, with legacy
|
||||
// DESIGN.json fallback for existing projects.
|
||||
const CONTEXT_DIR = resolveContextDir(process.cwd());
|
||||
const PROJECT_CONTEXT = loadContext(process.cwd());
|
||||
const CONTEXT_DIR = PROJECT_CONTEXT.contextDir;
|
||||
const DESIGN_MD_PATH = PROJECT_CONTEXT.designPath
|
||||
? path.resolve(process.cwd(), PROJECT_CONTEXT.designPath)
|
||||
: null;
|
||||
const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway
|
||||
const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s
|
||||
|
||||
@@ -371,10 +375,7 @@ function hasProjectContext() {
|
||||
// PRODUCT.md carries brand voice / anti-references — that's what determines
|
||||
// whether variants are brand-aware. DESIGN.md (visual tokens) is a separate
|
||||
// concern, surfaced by the design panel's own empty state.
|
||||
try {
|
||||
fs.accessSync(path.join(CONTEXT_DIR, 'PRODUCT.md'), fs.constants.R_OK);
|
||||
return true;
|
||||
} catch { return false; }
|
||||
return !!PROJECT_CONTEXT.hasProduct;
|
||||
}
|
||||
|
||||
function statOrNull(filePath) {
|
||||
@@ -549,8 +550,8 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
const token = url.searchParams.get('token');
|
||||
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
|
||||
|
||||
const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md');
|
||||
const jsonPath = resolveDesignSidecarPath(process.cwd(), CONTEXT_DIR) || getDesignSidecarPath(process.cwd());
|
||||
const mdPath = DESIGN_MD_PATH;
|
||||
const jsonPath = resolveDesignSidecarPath(process.cwd(), PROJECT_CONTEXT.designContextDir || CONTEXT_DIR) || getDesignSidecarPath(process.cwd());
|
||||
const mdStat = statOrNull(mdPath);
|
||||
const jsonStat = statOrNull(jsonPath);
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import path from 'node:path';
|
||||
import { resolveProjectRoot } from './context.mjs';
|
||||
import { parseTargetPath } from './lib/target-args.mjs';
|
||||
|
||||
export function resolveLiveTarget(cwd = process.cwd(), args = []) {
|
||||
const originalCwd = path.resolve(cwd);
|
||||
let targetPath = null;
|
||||
try {
|
||||
targetPath = parseTargetPath(args, { strict: true });
|
||||
} catch (err) {
|
||||
if (err?.name === 'TargetArgError') {
|
||||
process.stderr.write(`${err.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const absoluteTargetPath = targetPath
|
||||
? path.isAbsolute(targetPath) ? targetPath : path.resolve(originalCwd, targetPath)
|
||||
: null;
|
||||
const projectRoot = targetPath
|
||||
? resolveProjectRoot(originalCwd, { targetPath: absoluteTargetPath })
|
||||
: originalCwd;
|
||||
return {
|
||||
originalCwd,
|
||||
projectRoot,
|
||||
targetPath,
|
||||
absoluteTargetPath,
|
||||
targetOptions: absoluteTargetPath ? { targetPath: absoluteTargetPath } : {},
|
||||
};
|
||||
}
|
||||
@@ -21,14 +21,16 @@ import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadContext } from './context.mjs';
|
||||
import { loadContext, resolveTargetSelection } from './context.mjs';
|
||||
import { resolveFiles } from './live-inject.mjs';
|
||||
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
|
||||
import { resolveLiveTarget } from './live-target.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
async function liveCli() {
|
||||
const args = process.argv.slice(2);
|
||||
const liveTarget = resolveLiveTarget(process.cwd(), args);
|
||||
|
||||
if (args.includes('--help') || args.includes('-h')) {
|
||||
console.log(`Usage: node live.mjs
|
||||
@@ -38,37 +40,78 @@ Prepare everything for live variant mode in a single command:
|
||||
- Starts (or reuses) the live server in the background
|
||||
- Injects the browser script tag
|
||||
- Reads PRODUCT.md / DESIGN.md for project context
|
||||
- In monorepos, choose a child app first; --target <path> is the fallback/manual path
|
||||
|
||||
On success, prints a JSON blob with:
|
||||
{ ok, serverPort, serverToken, pageFile, hasContext, context }
|
||||
{ ok, serverPort, serverToken, pageFiles, projectRoot, repoRoot, targetPath, productPath, designPath }
|
||||
|
||||
On target_selection_required, prints:
|
||||
{ ok: false, error: "target_selection_required", targetCandidates }
|
||||
|
||||
On config_missing, prints:
|
||||
{ ok: false, error: "config_missing", configPath, hint }
|
||||
|
||||
The agent should then:
|
||||
1. If config_missing, create the config and re-run this script
|
||||
2. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort)
|
||||
3. Enter the poll loop: node live-poll.mjs`);
|
||||
1. If target_selection_required, ask which app to use and rerun from that child cwd
|
||||
2. If config_missing, create the config and re-run this script
|
||||
3. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort)
|
||||
4. Enter the poll loop: node live-poll.mjs`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const targetSelection = resolveTargetSelection(liveTarget.originalCwd, liveTarget.targetOptions);
|
||||
if (targetSelection) {
|
||||
console.log(JSON.stringify({
|
||||
ok: false,
|
||||
error: 'target_selection_required',
|
||||
...targetSelection,
|
||||
hint: 'Ask the user which app Impeccable should use, then rerun live from that child app cwd. Use --target <path> only as a fallback or explicit path diagnostic.',
|
||||
}, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const ctx = loadContext(liveTarget.originalCwd, liveTarget.targetOptions);
|
||||
const activeCwd = ctx.projectRoot;
|
||||
const outputTargetPath = liveTarget.targetPath || null;
|
||||
|
||||
const missingContext = missingLiveContext(ctx);
|
||||
if (missingContext.length > 0) {
|
||||
console.log(JSON.stringify({
|
||||
ok: false,
|
||||
error: 'context_missing',
|
||||
missing: missingContext,
|
||||
nextCommand: missingContext.includes('PRODUCT.md') ? 'init' : 'document',
|
||||
targetPath: outputTargetPath,
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
productPath: ctx.productPath,
|
||||
designPath: ctx.designPath,
|
||||
}, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 1. Check config (fail fast if missing — no point starting anything else)
|
||||
const checkOut = runScript('live-inject.mjs', ['--check']);
|
||||
const checkOut = runScript('live-inject.mjs', ['--check'], { cwd: activeCwd });
|
||||
const checkResult = safeParse(checkOut);
|
||||
if (!checkResult || !checkResult.ok) {
|
||||
console.log(JSON.stringify(checkResult || { ok: false, error: 'check_failed', raw: checkOut }));
|
||||
console.log(JSON.stringify({
|
||||
...(checkResult || { ok: false, error: 'check_failed', raw: checkOut }),
|
||||
targetPath: outputTargetPath,
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
}));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 2. Start server (or reuse existing)
|
||||
const serverInfo = ensureServerRunning();
|
||||
const serverInfo = ensureServerRunning(activeCwd);
|
||||
if (!serverInfo) {
|
||||
console.log(JSON.stringify({ ok: false, error: 'server_start_failed' }));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 3. Inject the script tag at the current port
|
||||
const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)]);
|
||||
const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)], { cwd: activeCwd });
|
||||
const injectResult = safeParse(injectOut);
|
||||
if (!injectResult || !injectResult.ok) {
|
||||
console.log(JSON.stringify({
|
||||
@@ -80,22 +123,23 @@ The agent should then:
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 4. Load PRODUCT.md + DESIGN.md context.
|
||||
const ctx = loadContext(process.cwd());
|
||||
|
||||
// 5. Compute drift-heal: compare resolved inject targets against the
|
||||
// 4. 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);
|
||||
const resolvedFiles = resolveFiles(activeCwd, checkResult.config);
|
||||
const drift = scanForDrift(activeCwd, resolvedFiles, checkResult.config);
|
||||
|
||||
// 6. Emit everything the agent needs
|
||||
// 5. Emit everything the agent needs
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
serverPort: serverInfo.port,
|
||||
serverToken: serverInfo.token,
|
||||
pageFiles: resolvedFiles,
|
||||
liveConfigPath: checkResult.path,
|
||||
configDrift: drift,
|
||||
targetPath: outputTargetPath,
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
hasProduct: ctx.hasProduct,
|
||||
product: ctx.product,
|
||||
productPath: ctx.productPath,
|
||||
@@ -105,6 +149,13 @@ The agent should then:
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
function missingLiveContext(ctx) {
|
||||
const missing = [];
|
||||
if (!ctx.hasProduct) missing.push('PRODUCT.md');
|
||||
if (!ctx.hasDesign) missing.push('DESIGN.md');
|
||||
return missing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drift-heal scan. Walks the project for HTML files under common
|
||||
* page-source directories (public/, src/, app/, pages/) and reports any
|
||||
@@ -201,11 +252,11 @@ function globToRegex(pattern) {
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function runScript(name, args) {
|
||||
function runScript(name, args, options = {}) {
|
||||
const scriptPath = path.join(__dirname, name);
|
||||
const cmd = `node "${scriptPath}" ${args.map(a => `"${a}"`).join(' ')}`;
|
||||
try {
|
||||
return execSync(cmd, { encoding: 'utf-8', cwd: process.cwd(), timeout: 15_000 });
|
||||
return execSync(cmd, { encoding: 'utf-8', cwd: options.cwd || process.cwd(), timeout: 15_000 });
|
||||
} catch (err) {
|
||||
// execSync throws on non-zero exit; return stdout if any
|
||||
return err.stdout || err.message || '';
|
||||
@@ -219,10 +270,10 @@ function safeParse(out) {
|
||||
/**
|
||||
* Return { pid, port, token } for the running live server, starting one if needed.
|
||||
*/
|
||||
function ensureServerRunning() {
|
||||
function ensureServerRunning(cwd = process.cwd()) {
|
||||
// Try to reuse an existing server
|
||||
try {
|
||||
const existing = readLiveServerInfo(process.cwd())?.info;
|
||||
const existing = readLiveServerInfo(cwd)?.info;
|
||||
if (existing && existing.pid) {
|
||||
try {
|
||||
process.kill(existing.pid, 0); // throws if dead
|
||||
@@ -232,7 +283,7 @@ function ensureServerRunning() {
|
||||
} catch { /* no PID file */ }
|
||||
|
||||
// Start a new server
|
||||
const out = runScript('live-server.mjs', ['--background']);
|
||||
const out = runScript('live-server.mjs', ['--background'], { cwd });
|
||||
return safeParse(out);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ Designs and iterates production-grade frontend interfaces. Real working code, co
|
||||
|
||||
You MUST do these steps before proceeding:
|
||||
|
||||
1. Run `node .github/skills/impeccable/scripts/context.mjs` once per session. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task.
|
||||
1. Run `node .github/skills/impeccable/scripts/context.mjs` once per session. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .github/skills/impeccable/scripts/context.mjs --target <path>` instead. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task.
|
||||
2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read `reference/<command>.md` next. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects.
|
||||
3. Familiarize yourself with any existing design system, conventions, and components in the code. Read at least one project file (CSS / tokens / theme / a representative component or page). **Required even when you've loaded a sub-command reference in step 2.** Don't reinvent the wheel; use what's there when it works, branch out when the UX wins.
|
||||
4. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md.
|
||||
|
||||
@@ -8,7 +8,7 @@ A running dev server with hot module replacement (Vite, Next.js, Bun, etc.), OR
|
||||
|
||||
Execute in order. No step skipped, no step reordered.
|
||||
|
||||
1. `live.mjs`: boot.
|
||||
1. `live.mjs`: boot. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .github/skills/impeccable/scripts/live.mjs --target <path>` instead; then run the rest of this live session from the returned `projectRoot`.
|
||||
2. Open the app URL that serves `pageFile` (infer from `package.json`, docs, terminal output, or an open tab). Never use `serverPort`; it's the helper, not the app. **Cursor:** `browser_navigate` to that URL before polling; do not skip. **Other harnesses:** use the available browser tool; if the URL is uncertain, ask the user once.
|
||||
3. Poll loop with the default long timeout (600000 ms). After every event or `--reply`, run `live-poll.mjs` again immediately. Never pass a short `--timeout=`.
|
||||
|
||||
|
||||
@@ -5,11 +5,12 @@
|
||||
* init flow.
|
||||
*
|
||||
* Path resolution (first match wins):
|
||||
* 1. cwd, if PRODUCT.md or DESIGN.md is there
|
||||
* 2. .agents/context/ then docs/
|
||||
* 3. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user
|
||||
* 1. Active project root, if PRODUCT.md or DESIGN.md is there
|
||||
* 2. Active project .agents/context/ then docs/
|
||||
* 3. Monorepo root context, using the same order, as a per-file fallback
|
||||
* 4. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user
|
||||
* escape hatch, only consulted when defaults are empty
|
||||
* 4. cwd as a "nothing found" default
|
||||
* 5. Active project root as a "nothing found" default
|
||||
*
|
||||
* `resolveContextDir()` and `loadContext()` are also exported for the
|
||||
* server-side scripts (live.mjs, live-server.mjs) that need the structured
|
||||
@@ -19,10 +20,25 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseTargetOptions } from './lib/target-args.mjs';
|
||||
|
||||
const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md'];
|
||||
const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md'];
|
||||
const FALLBACK_DIRS = ['.agents/context', 'docs'];
|
||||
const MONOREPO_MARKER_FILES = ['pnpm-workspace.yaml', 'turbo.json', 'nx.json', 'lerna.json'];
|
||||
const MONOREPO_FALLBACK_PROJECT_DIRS = ['apps', 'packages'];
|
||||
const WORKSPACE_DISCOVERY_IGNORED_DIRS = new Set([
|
||||
'node_modules',
|
||||
'.git',
|
||||
'dist',
|
||||
'build',
|
||||
'.next',
|
||||
'.nuxt',
|
||||
'.svelte-kit',
|
||||
'.turbo',
|
||||
'.cache',
|
||||
'coverage',
|
||||
]);
|
||||
|
||||
// ─── Update check ──────────────────────────────────────────────────────────
|
||||
// Piggyback a lightweight skill-version check on the once-per-session boot.
|
||||
@@ -38,41 +54,600 @@ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // throttle the network poll to o
|
||||
const RENOTIFY_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000; // don't re-surface the same version for a week
|
||||
const FETCH_TIMEOUT_MS = 1200;
|
||||
|
||||
export function resolveContextDir(cwd = process.cwd()) {
|
||||
if (firstExisting(cwd, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return cwd;
|
||||
}
|
||||
for (const rel of FALLBACK_DIRS) {
|
||||
const candidate = path.resolve(cwd, rel);
|
||||
if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
const envDir = process.env.IMPECCABLE_CONTEXT_DIR;
|
||||
if (envDir && envDir.trim()) {
|
||||
const trimmed = envDir.trim();
|
||||
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
||||
}
|
||||
return cwd;
|
||||
export function resolveContextDir(cwd = process.cwd(), options = {}) {
|
||||
return resolveContext(cwd, options).contextDir;
|
||||
}
|
||||
|
||||
export function loadContext(cwd = process.cwd()) {
|
||||
const contextDir = resolveContextDir(cwd);
|
||||
const productPath = firstExisting(contextDir, PRODUCT_NAMES);
|
||||
const designPath = firstExisting(contextDir, DESIGN_NAMES);
|
||||
export function loadContext(cwd = process.cwd(), options = {}) {
|
||||
const resolved = resolveContext(cwd, options);
|
||||
const absCwd = path.resolve(cwd);
|
||||
const productPath = resolved.productPath;
|
||||
const designPath = resolved.designPath;
|
||||
const product = productPath ? safeRead(productPath) : null;
|
||||
const design = designPath ? safeRead(designPath) : null;
|
||||
return {
|
||||
hasProduct: !!product,
|
||||
product,
|
||||
productPath: productPath ? path.relative(cwd, productPath) : null,
|
||||
productPath: productPath ? path.relative(absCwd, productPath) : null,
|
||||
hasDesign: !!design,
|
||||
design,
|
||||
designPath: designPath ? path.relative(cwd, designPath) : null,
|
||||
contextDir,
|
||||
designPath: designPath ? path.relative(absCwd, designPath) : null,
|
||||
contextDir: resolved.contextDir,
|
||||
productContextDir: productPath ? path.dirname(productPath) : null,
|
||||
designContextDir: designPath ? path.dirname(designPath) : null,
|
||||
projectRoot: resolved.projectRoot,
|
||||
repoRoot: resolved.repoRoot,
|
||||
isMonorepo: resolved.isMonorepo,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveContext(cwd = process.cwd(), options = {}) {
|
||||
const absCwd = path.resolve(cwd);
|
||||
const project = resolveProject(absCwd, options);
|
||||
const projectContextDir = resolveLocalContextDir(project.projectRoot);
|
||||
const rootContextDir = project.isMonorepo && project.repoRoot !== project.projectRoot
|
||||
? resolveLocalContextDir(project.repoRoot)
|
||||
: null;
|
||||
|
||||
let productPath =
|
||||
(projectContextDir ? firstExisting(projectContextDir, PRODUCT_NAMES) : null)
|
||||
|| (rootContextDir ? firstExisting(rootContextDir, PRODUCT_NAMES) : null);
|
||||
let designPath =
|
||||
(projectContextDir ? firstExisting(projectContextDir, DESIGN_NAMES) : null)
|
||||
|| (rootContextDir ? firstExisting(rootContextDir, DESIGN_NAMES) : null);
|
||||
|
||||
let envContextDir = null;
|
||||
if (!productPath && !designPath) {
|
||||
envContextDir = resolveEnvContextDir(absCwd);
|
||||
if (envContextDir) {
|
||||
productPath = firstExisting(envContextDir, PRODUCT_NAMES);
|
||||
designPath = firstExisting(envContextDir, DESIGN_NAMES);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
contextDir: productPath
|
||||
? path.dirname(productPath)
|
||||
: designPath
|
||||
? path.dirname(designPath)
|
||||
: envContextDir || project.projectRoot,
|
||||
productPath,
|
||||
designPath,
|
||||
projectRoot: project.projectRoot,
|
||||
repoRoot: project.repoRoot,
|
||||
isMonorepo: project.isMonorepo,
|
||||
targetDir: project.targetDir,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveProjectRoot(cwd = process.cwd(), options = {}) {
|
||||
return resolveProject(cwd, options).projectRoot;
|
||||
}
|
||||
|
||||
export function resolveTargetSelection(cwd = process.cwd(), options = {}) {
|
||||
if (hasTargetOption(options)) return null;
|
||||
const project = resolveProject(cwd);
|
||||
if (
|
||||
!project.isMonorepo
|
||||
|| !project.projectRoot
|
||||
|| !project.repoRoot
|
||||
|| path.resolve(project.projectRoot) !== path.resolve(project.repoRoot)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
targetPath: null,
|
||||
projectRoot: project.projectRoot,
|
||||
repoRoot: project.repoRoot,
|
||||
targetCandidates: discoverTargetCandidates(project.repoRoot),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveProject(cwd = process.cwd(), options = {}) {
|
||||
const absCwd = path.resolve(cwd);
|
||||
const targetDir = resolveTargetDir(absCwd, options);
|
||||
let repoRoot = findMonorepoRoot(targetDir);
|
||||
if (!repoRoot && targetDir !== absCwd) {
|
||||
const cwdRepoRoot = findMonorepoRoot(absCwd);
|
||||
if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {
|
||||
repoRoot = cwdRepoRoot;
|
||||
}
|
||||
}
|
||||
if (!repoRoot) {
|
||||
return {
|
||||
targetDir,
|
||||
projectRoot: absCwd,
|
||||
repoRoot: absCwd,
|
||||
isMonorepo: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
targetDir,
|
||||
projectRoot: resolveWorkspaceProjectRoot(repoRoot, targetDir) || repoRoot,
|
||||
repoRoot,
|
||||
isMonorepo: true,
|
||||
};
|
||||
}
|
||||
|
||||
function isPathInside(candidate, root) {
|
||||
const rel = path.relative(root, candidate);
|
||||
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
|
||||
}
|
||||
|
||||
function resolveLocalContextDir(root) {
|
||||
if (firstExisting(root, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return root;
|
||||
}
|
||||
for (const rel of FALLBACK_DIRS) {
|
||||
const candidate = path.resolve(root, rel);
|
||||
if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveEnvContextDir(cwd) {
|
||||
const envDir = process.env.IMPECCABLE_CONTEXT_DIR;
|
||||
if (!envDir || !envDir.trim()) return null;
|
||||
const trimmed = envDir.trim();
|
||||
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
||||
}
|
||||
|
||||
function resolveTargetDir(cwd, options = {}) {
|
||||
const targetPath = options && typeof options === 'object' ? options.targetPath : null;
|
||||
if (!targetPath || !String(targetPath).trim()) return cwd;
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
try {
|
||||
const stat = fs.statSync(abs);
|
||||
return stat.isDirectory() ? abs : path.dirname(abs);
|
||||
} catch {
|
||||
return path.extname(abs) ? path.dirname(abs) : abs;
|
||||
}
|
||||
}
|
||||
|
||||
function findMonorepoRoot(startDir) {
|
||||
let dir = path.resolve(startDir);
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
while (true) {
|
||||
if (dir === homeDir) return null;
|
||||
if (isMonorepoRoot(dir)) return dir;
|
||||
if (hasGitBoundary(dir)) return null;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function isMonorepoRoot(dir) {
|
||||
if (readWorkspacePatterns(dir).some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) return true;
|
||||
if (!MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(dir, file)))) return false;
|
||||
return hasFallbackWorkspaceChildren(dir);
|
||||
}
|
||||
|
||||
function hasGitBoundary(dir) {
|
||||
return fs.existsSync(path.join(dir, '.git'));
|
||||
}
|
||||
|
||||
function hasFallbackWorkspaceChildren(dir) {
|
||||
for (const name of MONOREPO_FALLBACK_PROJECT_DIRS) {
|
||||
const base = path.join(dir, name);
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(base, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (entries.some((entry) => entry.isDirectory() && !isIgnoredWorkspaceDiscoveryDir(entry.name))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function discoverTargetCandidates(repoRoot) {
|
||||
const roots = new Map();
|
||||
for (const pattern of readWorkspacePatterns(repoRoot)) {
|
||||
for (const root of discoverRootsForPattern(repoRoot, pattern)) {
|
||||
roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root);
|
||||
}
|
||||
}
|
||||
if (MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(repoRoot, file)))) {
|
||||
for (const name of MONOREPO_FALLBACK_PROJECT_DIRS) {
|
||||
const base = path.join(repoRoot, name);
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(base, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
||||
const root = path.join(base, entry.name);
|
||||
roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...roots.entries()]
|
||||
.filter(([rel]) => rel && !rel.startsWith('..'))
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([rel, root]) => {
|
||||
const targetExample = findTargetExample(repoRoot, root);
|
||||
return {
|
||||
name: path.basename(root),
|
||||
path: rel,
|
||||
targetExample,
|
||||
...resolveCandidateContextSummary(repoRoot, root, targetExample),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function resolveCandidateContextSummary(repoRoot, projectRoot, targetPath) {
|
||||
const ctx = resolveContext(repoRoot, { targetPath });
|
||||
return {
|
||||
productStatus: contextSourceStatus(ctx.productPath, repoRoot, projectRoot),
|
||||
productPath: contextSourcePath(ctx.productPath, repoRoot),
|
||||
designStatus: contextSourceStatus(ctx.designPath, repoRoot, projectRoot),
|
||||
designPath: contextSourcePath(ctx.designPath, repoRoot),
|
||||
};
|
||||
}
|
||||
|
||||
function contextSourceStatus(filePath, repoRoot, projectRoot) {
|
||||
if (!filePath) return 'missing';
|
||||
const absPath = path.resolve(filePath);
|
||||
const absProjectRoot = path.resolve(projectRoot);
|
||||
const absRepoRoot = path.resolve(repoRoot);
|
||||
if (isPathInsideOrEqual(absPath, absProjectRoot)) {
|
||||
return path.dirname(absPath) === absProjectRoot ? 'child' : 'fallback';
|
||||
}
|
||||
if (absProjectRoot !== absRepoRoot && isPathInsideOrEqual(absPath, absRepoRoot)) {
|
||||
return 'inherited';
|
||||
}
|
||||
return 'fallback';
|
||||
}
|
||||
|
||||
function contextSourcePath(filePath, repoRoot) {
|
||||
if (!filePath) return null;
|
||||
const rel = path.relative(repoRoot, filePath);
|
||||
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {
|
||||
return rel.split(path.sep).join('/');
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function discoverRootsForPattern(repoRoot, rawPattern) {
|
||||
const pattern = normalizeWorkspacePattern(rawPattern);
|
||||
if (!pattern || pattern.startsWith('!')) return [];
|
||||
const segments = pattern.split('/').filter(Boolean);
|
||||
if (!segments.length) return [];
|
||||
const firstGlobIndex = segments.findIndex((segment) => segment.includes('*'));
|
||||
const literalPrefix = firstGlobIndex === -1 ? segments : segments.slice(0, firstGlobIndex);
|
||||
const base = path.join(repoRoot, ...literalPrefix);
|
||||
if (!fs.existsSync(base)) return [];
|
||||
if (segments.includes('**')) {
|
||||
const packageRoots = [];
|
||||
walkDirs(base, (dir) => {
|
||||
if (dir !== base && isCandidateProjectRoot(dir)) packageRoots.push(dir);
|
||||
});
|
||||
if (packageRoots.length) return packageRoots;
|
||||
return directChildDirs(base);
|
||||
}
|
||||
return expandSimplePattern(repoRoot, segments);
|
||||
}
|
||||
|
||||
function expandSimplePattern(repoRoot, patternSegments, index = 0, current = repoRoot) {
|
||||
if (index >= patternSegments.length) return fs.existsSync(current) ? [current] : [];
|
||||
const segment = patternSegments[index];
|
||||
if (!segment.includes('*')) {
|
||||
return expandSimplePattern(repoRoot, patternSegments, index + 1, path.join(current, segment));
|
||||
}
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(current, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const roots = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
||||
if (!segmentMatches(segment, entry.name)) continue;
|
||||
roots.push(...expandSimplePattern(repoRoot, patternSegments, index + 1, path.join(current, entry.name)));
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
function directChildDirs(dir) {
|
||||
try {
|
||||
return fs.readdirSync(dir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() && !isIgnoredWorkspaceDiscoveryDir(entry.name))
|
||||
.map((entry) => path.join(dir, entry.name));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function walkDirs(root, visit) {
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(root, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
||||
const dir = path.join(root, entry.name);
|
||||
visit(dir);
|
||||
walkDirs(dir, visit);
|
||||
}
|
||||
}
|
||||
|
||||
function isCandidateProjectRoot(dir) {
|
||||
return !!(
|
||||
fs.existsSync(path.join(dir, 'package.json'))
|
||||
|| firstExisting(dir, [...PRODUCT_NAMES, ...DESIGN_NAMES])
|
||||
|| fs.existsSync(path.join(dir, 'src'))
|
||||
|| fs.existsSync(path.join(dir, 'app'))
|
||||
|| fs.existsSync(path.join(dir, 'pages'))
|
||||
|| fs.existsSync(path.join(dir, 'public'))
|
||||
);
|
||||
}
|
||||
|
||||
function isIgnoredWorkspaceDiscoveryDir(name) {
|
||||
return name.startsWith('.') || WORKSPACE_DISCOVERY_IGNORED_DIRS.has(name);
|
||||
}
|
||||
|
||||
function findTargetExample(repoRoot, projectRoot) {
|
||||
const examples = [
|
||||
'src/App.jsx',
|
||||
'src/App.tsx',
|
||||
'src/main.jsx',
|
||||
'src/main.tsx',
|
||||
'src/index.jsx',
|
||||
'src/index.ts',
|
||||
'app/page.tsx',
|
||||
'pages/index.tsx',
|
||||
'public/index.html',
|
||||
];
|
||||
for (const rel of examples) {
|
||||
const abs = path.join(projectRoot, rel);
|
||||
if (fs.existsSync(abs)) return path.relative(repoRoot, abs).split(path.sep).join('/');
|
||||
}
|
||||
return path.relative(repoRoot, projectRoot).split(path.sep).join('/');
|
||||
}
|
||||
|
||||
function resolveWorkspaceProjectRoot(repoRoot, targetDir) {
|
||||
const rel = path.relative(repoRoot, targetDir);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return repoRoot;
|
||||
const relSegments = rel.split(path.sep).filter(Boolean);
|
||||
const patterns = readWorkspacePatterns(repoRoot);
|
||||
const excluded = isExcludedByWorkspacePattern(relSegments, patterns);
|
||||
if (!excluded) {
|
||||
for (const pattern of patterns) {
|
||||
const projectRoot = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern);
|
||||
if (projectRoot) return projectRoot;
|
||||
}
|
||||
}
|
||||
if (excluded) return repoRoot;
|
||||
if (
|
||||
relSegments.length >= 2
|
||||
&& MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0])
|
||||
) {
|
||||
return path.join(repoRoot, relSegments[0], relSegments[1]);
|
||||
}
|
||||
const nearest = nearestProjectLikeRoot(repoRoot, targetDir);
|
||||
if (nearest) return nearest;
|
||||
return repoRoot;
|
||||
}
|
||||
|
||||
function isExcludedByWorkspacePattern(relSegments, patterns) {
|
||||
return patterns.some((rawPattern) => {
|
||||
const pattern = normalizeWorkspacePattern(rawPattern);
|
||||
if (!pattern.startsWith('!')) return false;
|
||||
return workspacePatternMatchesRel(pattern.slice(1), relSegments);
|
||||
});
|
||||
}
|
||||
|
||||
function nearestProjectLikeRoot(repoRoot, targetDir) {
|
||||
let dir = path.resolve(targetDir);
|
||||
const stop = path.resolve(repoRoot);
|
||||
while (dir && dir !== stop) {
|
||||
if (
|
||||
firstExisting(dir, [...PRODUCT_NAMES, ...DESIGN_NAMES])
|
||||
|| fs.existsSync(path.join(dir, 'package.json'))
|
||||
) {
|
||||
return dir;
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function nearestPackageRootBetween(repoRoot, targetDir, stopDir) {
|
||||
let dir = path.resolve(targetDir);
|
||||
const stop = path.resolve(stopDir || repoRoot);
|
||||
const root = path.resolve(repoRoot);
|
||||
while (dir && dir !== stop && isPathInsideOrEqual(dir, root)) {
|
||||
if (fs.existsSync(path.join(dir, 'package.json'))) return dir;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPathInsideOrEqual(candidate, root) {
|
||||
return path.resolve(candidate) === path.resolve(root) || isPathInside(candidate, root);
|
||||
}
|
||||
|
||||
function workspacePatternMatchesRel(pattern, relSegments) {
|
||||
const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return false;
|
||||
if (patternSegments.includes('**')) {
|
||||
const firstGlobIndex = patternSegments.findIndex((segment) => segment.includes('*'));
|
||||
const literalPrefix = firstGlobIndex === -1
|
||||
? patternSegments
|
||||
: patternSegments.slice(0, firstGlobIndex);
|
||||
if (relSegments.length < literalPrefix.length + 1) return false;
|
||||
for (let i = 0; i < literalPrefix.length; i++) {
|
||||
if (!segmentMatches(literalPrefix[i], relSegments[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (relSegments.length < patternSegments.length) return false;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function readWorkspacePatterns(repoRoot) {
|
||||
return [
|
||||
...readPackageWorkspaces(repoRoot),
|
||||
...readPnpmWorkspaces(repoRoot),
|
||||
...readLernaWorkspaces(repoRoot),
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
function readPackageWorkspaces(repoRoot) {
|
||||
const pkg = readJson(path.join(repoRoot, 'package.json'));
|
||||
const workspaces = pkg?.workspaces;
|
||||
if (Array.isArray(workspaces)) return workspaces;
|
||||
if (Array.isArray(workspaces?.packages)) return workspaces.packages;
|
||||
return [];
|
||||
}
|
||||
|
||||
function readLernaWorkspaces(repoRoot) {
|
||||
const lerna = readJson(path.join(repoRoot, 'lerna.json'));
|
||||
return Array.isArray(lerna?.packages) ? lerna.packages : [];
|
||||
}
|
||||
|
||||
function readPnpmWorkspaces(repoRoot) {
|
||||
try {
|
||||
const body = fs.readFileSync(path.join(repoRoot, 'pnpm-workspace.yaml'), 'utf-8');
|
||||
const patterns = [];
|
||||
let inPackages = false;
|
||||
for (const line of body.split(/\r?\n/)) {
|
||||
const trimmed = stripYamlInlineComment(line).trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const flowMatch = trimmed.match(/^packages:\s*\[(.*)\]\s*$/);
|
||||
if (flowMatch) {
|
||||
patterns.push(...parseYamlFlowList(flowMatch[1]));
|
||||
inPackages = false;
|
||||
continue;
|
||||
}
|
||||
if (/^packages:\s*$/.test(trimmed)) {
|
||||
inPackages = true;
|
||||
continue;
|
||||
}
|
||||
if (inPackages && /^[A-Za-z0-9_-]+:\s*/.test(trimmed)) break;
|
||||
if (inPackages) {
|
||||
const match = trimmed.match(/^-\s*(.+)$/);
|
||||
if (match) patterns.push(unquoteYamlValue(match[1]));
|
||||
}
|
||||
}
|
||||
return patterns;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function stripYamlInlineComment(line) {
|
||||
let quote = null;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
if ((ch === '"' || ch === "'") && line[i - 1] !== '\\') {
|
||||
quote = quote === ch ? null : quote || ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === '#' && !quote) return line.slice(0, i);
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
function parseYamlFlowList(body) {
|
||||
const items = [];
|
||||
let quote = null;
|
||||
let current = '';
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
const ch = body[i];
|
||||
if ((ch === '"' || ch === "'") && body[i - 1] !== '\\') {
|
||||
quote = quote === ch ? null : quote || ch;
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === ',' && !quote) {
|
||||
const value = unquoteYamlValue(current);
|
||||
if (value) items.push(value);
|
||||
current = '';
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
}
|
||||
const value = unquoteYamlValue(current);
|
||||
if (value) items.push(value);
|
||||
return items;
|
||||
}
|
||||
|
||||
function unquoteYamlValue(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '');
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function projectRootFromWorkspacePattern(repoRoot, relSegments, rawPattern) {
|
||||
const pattern = normalizeWorkspacePattern(rawPattern);
|
||||
if (!pattern || pattern.startsWith('!')) return null;
|
||||
const patternSegments = pattern.split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return null;
|
||||
if (patternSegments.includes('**')) {
|
||||
return projectRootFromDoubleStarPattern(repoRoot, relSegments, patternSegments);
|
||||
}
|
||||
if (relSegments.length < patternSegments.length) return null;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return null;
|
||||
}
|
||||
return path.join(repoRoot, ...relSegments.slice(0, patternSegments.length));
|
||||
}
|
||||
|
||||
function projectRootFromDoubleStarPattern(repoRoot, relSegments, patternSegments) {
|
||||
const firstGlobIndex = patternSegments.findIndex((segment) => segment.includes('*'));
|
||||
const literalPrefix = firstGlobIndex === -1
|
||||
? patternSegments
|
||||
: patternSegments.slice(0, firstGlobIndex);
|
||||
if (relSegments.length < literalPrefix.length + 1) return null;
|
||||
for (let i = 0; i < literalPrefix.length; i++) {
|
||||
if (!segmentMatches(literalPrefix[i], relSegments[i])) return null;
|
||||
}
|
||||
const prefixDir = path.join(repoRoot, ...literalPrefix);
|
||||
const targetDir = path.join(repoRoot, ...relSegments);
|
||||
const packageRoot = nearestPackageRootBetween(repoRoot, targetDir, prefixDir);
|
||||
if (packageRoot) return packageRoot;
|
||||
return path.join(repoRoot, ...relSegments.slice(0, literalPrefix.length + 1));
|
||||
}
|
||||
|
||||
function normalizeWorkspacePattern(pattern) {
|
||||
return String(pattern || '')
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '')
|
||||
.replace(/^\.\//, '')
|
||||
.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function segmentMatches(patternSegment, relSegment) {
|
||||
if (patternSegment === '*') return true;
|
||||
if (!patternSegment.includes('*')) return patternSegment === relSegment;
|
||||
const re = new RegExp(`^${escapeRegExp(patternSegment).replace(/\\\*/g, '[^/]*')}$`);
|
||||
return re.test(relSegment);
|
||||
}
|
||||
|
||||
function firstExisting(dir, names) {
|
||||
for (const name of names) {
|
||||
const abs = path.join(dir, name);
|
||||
@@ -89,6 +664,10 @@ function safeRead(p) {
|
||||
}
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the register (`brand` or `product`) out of PRODUCT.md by looking
|
||||
* for a `## Register` section and reading the first non-empty line that
|
||||
@@ -233,7 +812,24 @@ async function computeUpdateDirective(now = Date.now()) {
|
||||
}
|
||||
|
||||
async function cli() {
|
||||
const ctx = loadContext(process.cwd());
|
||||
let cliOptions;
|
||||
try {
|
||||
cliOptions = parseCliOptions(process.argv.slice(2));
|
||||
} catch (err) {
|
||||
if (err?.name === 'TargetArgError') {
|
||||
process.stderr.write(`${err.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const targetProvided = hasTargetOption(cliOptions);
|
||||
const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null;
|
||||
const selection = resolveTargetSelection(process.cwd(), cliOptions);
|
||||
if (selection) {
|
||||
process.stdout.write(buildTargetSelectionDirective(selection) + '\n');
|
||||
process.exit(0);
|
||||
}
|
||||
const ctx = loadContext(process.cwd(), cliOptions);
|
||||
const updateDirective = await computeUpdateDirective();
|
||||
|
||||
if (!ctx.hasProduct) {
|
||||
@@ -244,6 +840,10 @@ async function cli() {
|
||||
'Stop the current task, load reference/init.md, and follow its ' +
|
||||
'instructions to write PRODUCT.md before resuming.',
|
||||
];
|
||||
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
|
||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||
parts.push(buildMissingTargetDirective());
|
||||
}
|
||||
if (updateDirective) parts.push(updateDirective);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
process.exit(0);
|
||||
@@ -252,6 +852,10 @@ async function cli() {
|
||||
if (ctx.hasDesign) {
|
||||
parts.push(`# DESIGN.md\n\n${ctx.design.trim()}`);
|
||||
}
|
||||
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
|
||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||
parts.push(buildMissingTargetDirective());
|
||||
}
|
||||
const register = extractRegister(ctx.product);
|
||||
const next = register
|
||||
? `NEXT STEP: This project's register is \`${register}\`. You MUST now read \`reference/${register}.md\` before producing any design output.`
|
||||
@@ -261,6 +865,60 @@ async function cli() {
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
}
|
||||
|
||||
function parseCliOptions(args) {
|
||||
return parseTargetOptions(args, { strict: true });
|
||||
}
|
||||
|
||||
function hasTargetOption(options) {
|
||||
return !!(options && typeof options.targetPath === 'string' && options.targetPath.trim());
|
||||
}
|
||||
|
||||
function pathExistsForTarget(cwd, targetPath) {
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
return fs.existsSync(abs);
|
||||
}
|
||||
|
||||
function buildResolvedContextDirective(ctx, options, { targetExists = null } = {}) {
|
||||
const targetPath = hasTargetOption(options) ? options.targetPath : null;
|
||||
return `RESOLVED_CONTEXT:\n${JSON.stringify({
|
||||
targetPath,
|
||||
...(targetPath ? { targetExists } : {}),
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
productPath: ctx.productPath,
|
||||
designPath: ctx.designPath,
|
||||
}, null, 2)}`;
|
||||
}
|
||||
|
||||
function shouldWarnMissingTarget(ctx, targetProvided, targetExists = null) {
|
||||
if (ctx.isMonorepo && targetProvided && targetExists === false) return true;
|
||||
return !!(
|
||||
ctx.isMonorepo
|
||||
&& (!targetProvided || targetExists === false)
|
||||
&& ctx.projectRoot
|
||||
&& ctx.repoRoot
|
||||
&& path.resolve(ctx.projectRoot) === path.resolve(ctx.repoRoot)
|
||||
);
|
||||
}
|
||||
|
||||
function buildMissingTargetDirective() {
|
||||
const script = process.argv[1] || 'context.mjs';
|
||||
return (
|
||||
'MONOREPO_TARGET_REQUIRED: This is a monorepo and context.mjs ran without --target. ' +
|
||||
'If the user named a file, route, or child app, do not answer from this output. ' +
|
||||
`Rerun \`node ${script} --target <path>\` and answer from that run's RESOLVED_CONTEXT fields.`
|
||||
);
|
||||
}
|
||||
|
||||
function buildTargetSelectionDirective(selection) {
|
||||
return (
|
||||
`TARGET_SELECTION_REQUIRED:\n${JSON.stringify(selection, null, 2)}\n\n` +
|
||||
'Show each app with its productStatus/productPath and designStatus/designPath so the user can see child overrides, inherited root files, fallback files, or missing files before choosing. ' +
|
||||
'Ask the user which app Impeccable should use, then rerun Impeccable helper commands from that child app cwd using this same scripts directory. ' +
|
||||
'Use `--target <path>` only as a fallback when changing cwd is not possible, or when the user explicitly named a file/path.'
|
||||
);
|
||||
}
|
||||
|
||||
// Run cli() only when this module is the entry point. Compare realpaths
|
||||
// rather than endsWith(): a loose suffix match also fires for unrelated
|
||||
// scripts like `load-context.mjs`, and realpath tolerates symlinked
|
||||
|
||||
@@ -1,50 +1,52 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { resolveProjectRoot } from '../context.mjs';
|
||||
|
||||
export const IMPECCABLE_DIR = '.impeccable';
|
||||
export const LIVE_DIR = 'live';
|
||||
export const CRITIQUE_DIR = 'critique';
|
||||
|
||||
export function getImpeccableDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, IMPECCABLE_DIR);
|
||||
export function getImpeccableDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), IMPECCABLE_DIR);
|
||||
}
|
||||
|
||||
export function getDesignSidecarPath(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), 'design.json');
|
||||
export function getDesignSidecarPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), 'design.json');
|
||||
}
|
||||
|
||||
export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd) {
|
||||
export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd, options = {}) {
|
||||
const projectRoot = resolveProjectRoot(cwd, options);
|
||||
const candidates = [
|
||||
getDesignSidecarPath(cwd),
|
||||
path.join(cwd, 'DESIGN.json'),
|
||||
getDesignSidecarPath(cwd, options),
|
||||
path.join(projectRoot, 'DESIGN.json'),
|
||||
];
|
||||
const contextLegacy = path.join(contextDir, 'DESIGN.json');
|
||||
if (!candidates.includes(contextLegacy)) candidates.push(contextLegacy);
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) {
|
||||
return firstExisting(getDesignSidecarCandidates(cwd, contextDir));
|
||||
export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd, options = {}) {
|
||||
return firstExisting(getDesignSidecarCandidates(cwd, contextDir, options));
|
||||
}
|
||||
|
||||
export function getLiveDir(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), LIVE_DIR);
|
||||
export function getLiveDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), LIVE_DIR);
|
||||
}
|
||||
|
||||
export function getLiveConfigPath(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'config.json');
|
||||
export function getLiveConfigPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'config.json');
|
||||
}
|
||||
|
||||
export function getLegacyLiveConfigPath(scriptsDir) {
|
||||
return path.join(scriptsDir, 'config.json');
|
||||
}
|
||||
|
||||
export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env } = {}) {
|
||||
export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env, targetPath } = {}) {
|
||||
if (env.IMPECCABLE_LIVE_CONFIG && env.IMPECCABLE_LIVE_CONFIG.trim()) {
|
||||
const configured = env.IMPECCABLE_LIVE_CONFIG.trim();
|
||||
return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured);
|
||||
}
|
||||
const primary = getLiveConfigPath(cwd);
|
||||
const primary = getLiveConfigPath(cwd, { targetPath });
|
||||
if (fs.existsSync(primary)) return primary;
|
||||
if (scriptsDir) {
|
||||
const legacy = getLegacyLiveConfigPath(scriptsDir);
|
||||
@@ -53,16 +55,16 @@ export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = p
|
||||
return primary;
|
||||
}
|
||||
|
||||
export function getLiveServerPath(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'server.json');
|
||||
export function getLiveServerPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'server.json');
|
||||
}
|
||||
|
||||
export function getLegacyLiveServerPath(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live.json');
|
||||
export function getLegacyLiveServerPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live.json');
|
||||
}
|
||||
|
||||
export function readLiveServerInfo(cwd = process.cwd()) {
|
||||
for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) {
|
||||
export function readLiveServerInfo(cwd = process.cwd(), options = {}) {
|
||||
for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) {
|
||||
try {
|
||||
const info = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
if (info && typeof info.pid === 'number' && !isLiveServerPidReachable(info.pid)) {
|
||||
@@ -88,37 +90,37 @@ export function isLiveServerPidReachable(pid) {
|
||||
}
|
||||
}
|
||||
|
||||
export function writeLiveServerInfo(cwd = process.cwd(), info) {
|
||||
const filePath = getLiveServerPath(cwd);
|
||||
export function writeLiveServerInfo(cwd = process.cwd(), info, options = {}) {
|
||||
const filePath = getLiveServerPath(cwd, options);
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(info));
|
||||
return filePath;
|
||||
}
|
||||
|
||||
export function removeLiveServerInfo(cwd = process.cwd()) {
|
||||
for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) {
|
||||
export function removeLiveServerInfo(cwd = process.cwd(), options = {}) {
|
||||
for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) {
|
||||
try { fs.unlinkSync(filePath); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
export function getLiveSessionsDir(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'sessions');
|
||||
export function getLiveSessionsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'sessions');
|
||||
}
|
||||
|
||||
export function getLegacyLiveSessionsDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live', 'sessions');
|
||||
export function getLegacyLiveSessionsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'sessions');
|
||||
}
|
||||
|
||||
export function getLiveAnnotationsDir(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'annotations');
|
||||
export function getLiveAnnotationsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'annotations');
|
||||
}
|
||||
|
||||
export function getCritiqueDir(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), CRITIQUE_DIR);
|
||||
export function getCritiqueDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), CRITIQUE_DIR);
|
||||
}
|
||||
|
||||
export function getLegacyLiveAnnotationsDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live', 'annotations');
|
||||
export function getLegacyLiveAnnotationsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'annotations');
|
||||
}
|
||||
|
||||
function firstExisting(paths) {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
class TargetArgError extends Error {
|
||||
constructor(message, code) {
|
||||
super(message);
|
||||
this.name = 'TargetArgError';
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseTargetPath(args = [], { strict = false } = {}) {
|
||||
let targetPath = null;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = String(args[i]);
|
||||
if (arg === '--target' || arg === '-t') {
|
||||
const next = args[i + 1];
|
||||
if (next && !String(next).startsWith('-')) {
|
||||
targetPath = String(next);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (strict) {
|
||||
throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith('--target=')) {
|
||||
const value = arg.slice('--target='.length);
|
||||
if (value) {
|
||||
targetPath = value;
|
||||
continue;
|
||||
}
|
||||
if (strict) {
|
||||
throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING');
|
||||
}
|
||||
}
|
||||
}
|
||||
return targetPath;
|
||||
}
|
||||
|
||||
export function parseTargetOptions(args = [], options = {}) {
|
||||
const targetPath = parseTargetPath(args, options);
|
||||
return targetPath ? { targetPath } : {};
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import path from 'node:path';
|
||||
import net from 'node:net';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseDesignMd } from './lib/design-parser.mjs';
|
||||
import { resolveContextDir } from './context.mjs';
|
||||
import { loadContext } from './context.mjs';
|
||||
import {
|
||||
assembleLiveBrowserScript,
|
||||
assertLiveBrowserScriptParts,
|
||||
@@ -55,7 +55,11 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
// PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
|
||||
// DESIGN sidecar is project-local at .impeccable/design.json, with legacy
|
||||
// DESIGN.json fallback for existing projects.
|
||||
const CONTEXT_DIR = resolveContextDir(process.cwd());
|
||||
const PROJECT_CONTEXT = loadContext(process.cwd());
|
||||
const CONTEXT_DIR = PROJECT_CONTEXT.contextDir;
|
||||
const DESIGN_MD_PATH = PROJECT_CONTEXT.designPath
|
||||
? path.resolve(process.cwd(), PROJECT_CONTEXT.designPath)
|
||||
: null;
|
||||
const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway
|
||||
const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s
|
||||
|
||||
@@ -371,10 +375,7 @@ function hasProjectContext() {
|
||||
// PRODUCT.md carries brand voice / anti-references — that's what determines
|
||||
// whether variants are brand-aware. DESIGN.md (visual tokens) is a separate
|
||||
// concern, surfaced by the design panel's own empty state.
|
||||
try {
|
||||
fs.accessSync(path.join(CONTEXT_DIR, 'PRODUCT.md'), fs.constants.R_OK);
|
||||
return true;
|
||||
} catch { return false; }
|
||||
return !!PROJECT_CONTEXT.hasProduct;
|
||||
}
|
||||
|
||||
function statOrNull(filePath) {
|
||||
@@ -549,8 +550,8 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
const token = url.searchParams.get('token');
|
||||
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
|
||||
|
||||
const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md');
|
||||
const jsonPath = resolveDesignSidecarPath(process.cwd(), CONTEXT_DIR) || getDesignSidecarPath(process.cwd());
|
||||
const mdPath = DESIGN_MD_PATH;
|
||||
const jsonPath = resolveDesignSidecarPath(process.cwd(), PROJECT_CONTEXT.designContextDir || CONTEXT_DIR) || getDesignSidecarPath(process.cwd());
|
||||
const mdStat = statOrNull(mdPath);
|
||||
const jsonStat = statOrNull(jsonPath);
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import path from 'node:path';
|
||||
import { resolveProjectRoot } from './context.mjs';
|
||||
import { parseTargetPath } from './lib/target-args.mjs';
|
||||
|
||||
export function resolveLiveTarget(cwd = process.cwd(), args = []) {
|
||||
const originalCwd = path.resolve(cwd);
|
||||
let targetPath = null;
|
||||
try {
|
||||
targetPath = parseTargetPath(args, { strict: true });
|
||||
} catch (err) {
|
||||
if (err?.name === 'TargetArgError') {
|
||||
process.stderr.write(`${err.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const absoluteTargetPath = targetPath
|
||||
? path.isAbsolute(targetPath) ? targetPath : path.resolve(originalCwd, targetPath)
|
||||
: null;
|
||||
const projectRoot = targetPath
|
||||
? resolveProjectRoot(originalCwd, { targetPath: absoluteTargetPath })
|
||||
: originalCwd;
|
||||
return {
|
||||
originalCwd,
|
||||
projectRoot,
|
||||
targetPath,
|
||||
absoluteTargetPath,
|
||||
targetOptions: absoluteTargetPath ? { targetPath: absoluteTargetPath } : {},
|
||||
};
|
||||
}
|
||||
@@ -21,14 +21,16 @@ import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadContext } from './context.mjs';
|
||||
import { loadContext, resolveTargetSelection } from './context.mjs';
|
||||
import { resolveFiles } from './live-inject.mjs';
|
||||
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
|
||||
import { resolveLiveTarget } from './live-target.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
async function liveCli() {
|
||||
const args = process.argv.slice(2);
|
||||
const liveTarget = resolveLiveTarget(process.cwd(), args);
|
||||
|
||||
if (args.includes('--help') || args.includes('-h')) {
|
||||
console.log(`Usage: node live.mjs
|
||||
@@ -38,37 +40,78 @@ Prepare everything for live variant mode in a single command:
|
||||
- Starts (or reuses) the live server in the background
|
||||
- Injects the browser script tag
|
||||
- Reads PRODUCT.md / DESIGN.md for project context
|
||||
- In monorepos, choose a child app first; --target <path> is the fallback/manual path
|
||||
|
||||
On success, prints a JSON blob with:
|
||||
{ ok, serverPort, serverToken, pageFile, hasContext, context }
|
||||
{ ok, serverPort, serverToken, pageFiles, projectRoot, repoRoot, targetPath, productPath, designPath }
|
||||
|
||||
On target_selection_required, prints:
|
||||
{ ok: false, error: "target_selection_required", targetCandidates }
|
||||
|
||||
On config_missing, prints:
|
||||
{ ok: false, error: "config_missing", configPath, hint }
|
||||
|
||||
The agent should then:
|
||||
1. If config_missing, create the config and re-run this script
|
||||
2. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort)
|
||||
3. Enter the poll loop: node live-poll.mjs`);
|
||||
1. If target_selection_required, ask which app to use and rerun from that child cwd
|
||||
2. If config_missing, create the config and re-run this script
|
||||
3. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort)
|
||||
4. Enter the poll loop: node live-poll.mjs`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const targetSelection = resolveTargetSelection(liveTarget.originalCwd, liveTarget.targetOptions);
|
||||
if (targetSelection) {
|
||||
console.log(JSON.stringify({
|
||||
ok: false,
|
||||
error: 'target_selection_required',
|
||||
...targetSelection,
|
||||
hint: 'Ask the user which app Impeccable should use, then rerun live from that child app cwd. Use --target <path> only as a fallback or explicit path diagnostic.',
|
||||
}, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const ctx = loadContext(liveTarget.originalCwd, liveTarget.targetOptions);
|
||||
const activeCwd = ctx.projectRoot;
|
||||
const outputTargetPath = liveTarget.targetPath || null;
|
||||
|
||||
const missingContext = missingLiveContext(ctx);
|
||||
if (missingContext.length > 0) {
|
||||
console.log(JSON.stringify({
|
||||
ok: false,
|
||||
error: 'context_missing',
|
||||
missing: missingContext,
|
||||
nextCommand: missingContext.includes('PRODUCT.md') ? 'init' : 'document',
|
||||
targetPath: outputTargetPath,
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
productPath: ctx.productPath,
|
||||
designPath: ctx.designPath,
|
||||
}, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 1. Check config (fail fast if missing — no point starting anything else)
|
||||
const checkOut = runScript('live-inject.mjs', ['--check']);
|
||||
const checkOut = runScript('live-inject.mjs', ['--check'], { cwd: activeCwd });
|
||||
const checkResult = safeParse(checkOut);
|
||||
if (!checkResult || !checkResult.ok) {
|
||||
console.log(JSON.stringify(checkResult || { ok: false, error: 'check_failed', raw: checkOut }));
|
||||
console.log(JSON.stringify({
|
||||
...(checkResult || { ok: false, error: 'check_failed', raw: checkOut }),
|
||||
targetPath: outputTargetPath,
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
}));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 2. Start server (or reuse existing)
|
||||
const serverInfo = ensureServerRunning();
|
||||
const serverInfo = ensureServerRunning(activeCwd);
|
||||
if (!serverInfo) {
|
||||
console.log(JSON.stringify({ ok: false, error: 'server_start_failed' }));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 3. Inject the script tag at the current port
|
||||
const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)]);
|
||||
const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)], { cwd: activeCwd });
|
||||
const injectResult = safeParse(injectOut);
|
||||
if (!injectResult || !injectResult.ok) {
|
||||
console.log(JSON.stringify({
|
||||
@@ -80,22 +123,23 @@ The agent should then:
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 4. Load PRODUCT.md + DESIGN.md context.
|
||||
const ctx = loadContext(process.cwd());
|
||||
|
||||
// 5. Compute drift-heal: compare resolved inject targets against the
|
||||
// 4. 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);
|
||||
const resolvedFiles = resolveFiles(activeCwd, checkResult.config);
|
||||
const drift = scanForDrift(activeCwd, resolvedFiles, checkResult.config);
|
||||
|
||||
// 6. Emit everything the agent needs
|
||||
// 5. Emit everything the agent needs
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
serverPort: serverInfo.port,
|
||||
serverToken: serverInfo.token,
|
||||
pageFiles: resolvedFiles,
|
||||
liveConfigPath: checkResult.path,
|
||||
configDrift: drift,
|
||||
targetPath: outputTargetPath,
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
hasProduct: ctx.hasProduct,
|
||||
product: ctx.product,
|
||||
productPath: ctx.productPath,
|
||||
@@ -105,6 +149,13 @@ The agent should then:
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
function missingLiveContext(ctx) {
|
||||
const missing = [];
|
||||
if (!ctx.hasProduct) missing.push('PRODUCT.md');
|
||||
if (!ctx.hasDesign) missing.push('DESIGN.md');
|
||||
return missing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drift-heal scan. Walks the project for HTML files under common
|
||||
* page-source directories (public/, src/, app/, pages/) and reports any
|
||||
@@ -201,11 +252,11 @@ function globToRegex(pattern) {
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function runScript(name, args) {
|
||||
function runScript(name, args, options = {}) {
|
||||
const scriptPath = path.join(__dirname, name);
|
||||
const cmd = `node "${scriptPath}" ${args.map(a => `"${a}"`).join(' ')}`;
|
||||
try {
|
||||
return execSync(cmd, { encoding: 'utf-8', cwd: process.cwd(), timeout: 15_000 });
|
||||
return execSync(cmd, { encoding: 'utf-8', cwd: options.cwd || process.cwd(), timeout: 15_000 });
|
||||
} catch (err) {
|
||||
// execSync throws on non-zero exit; return stdout if any
|
||||
return err.stdout || err.message || '';
|
||||
@@ -219,10 +270,10 @@ function safeParse(out) {
|
||||
/**
|
||||
* Return { pid, port, token } for the running live server, starting one if needed.
|
||||
*/
|
||||
function ensureServerRunning() {
|
||||
function ensureServerRunning(cwd = process.cwd()) {
|
||||
// Try to reuse an existing server
|
||||
try {
|
||||
const existing = readLiveServerInfo(process.cwd())?.info;
|
||||
const existing = readLiveServerInfo(cwd)?.info;
|
||||
if (existing && existing.pid) {
|
||||
try {
|
||||
process.kill(existing.pid, 0); // throws if dead
|
||||
@@ -232,7 +283,7 @@ function ensureServerRunning() {
|
||||
} catch { /* no PID file */ }
|
||||
|
||||
// Start a new server
|
||||
const out = runScript('live-server.mjs', ['--background']);
|
||||
const out = runScript('live-server.mjs', ['--background'], { cwd });
|
||||
return safeParse(out);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ Designs and iterates production-grade frontend interfaces. Real working code, co
|
||||
|
||||
You MUST do these steps before proceeding:
|
||||
|
||||
1. Run `node .kiro/skills/impeccable/scripts/context.mjs` once per session. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task.
|
||||
1. Run `node .kiro/skills/impeccable/scripts/context.mjs` once per session. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .kiro/skills/impeccable/scripts/context.mjs --target <path>` instead. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task.
|
||||
2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read `reference/<command>.md` next. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects.
|
||||
3. Familiarize yourself with any existing design system, conventions, and components in the code. Read at least one project file (CSS / tokens / theme / a representative component or page). **Required even when you've loaded a sub-command reference in step 2.** Don't reinvent the wheel; use what's there when it works, branch out when the UX wins.
|
||||
4. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md.
|
||||
|
||||
@@ -8,7 +8,7 @@ A running dev server with hot module replacement (Vite, Next.js, Bun, etc.), OR
|
||||
|
||||
Execute in order. No step skipped, no step reordered.
|
||||
|
||||
1. `live.mjs`: boot.
|
||||
1. `live.mjs`: boot. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .kiro/skills/impeccable/scripts/live.mjs --target <path>` instead; then run the rest of this live session from the returned `projectRoot`.
|
||||
2. Open the app URL that serves `pageFile` (infer from `package.json`, docs, terminal output, or an open tab). Never use `serverPort`; it's the helper, not the app. **Cursor:** `browser_navigate` to that URL before polling; do not skip. **Other harnesses:** use the available browser tool; if the URL is uncertain, ask the user once.
|
||||
3. Poll loop with the default long timeout (600000 ms). After every event or `--reply`, run `live-poll.mjs` again immediately. Never pass a short `--timeout=`.
|
||||
|
||||
|
||||
@@ -5,11 +5,12 @@
|
||||
* init flow.
|
||||
*
|
||||
* Path resolution (first match wins):
|
||||
* 1. cwd, if PRODUCT.md or DESIGN.md is there
|
||||
* 2. .agents/context/ then docs/
|
||||
* 3. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user
|
||||
* 1. Active project root, if PRODUCT.md or DESIGN.md is there
|
||||
* 2. Active project .agents/context/ then docs/
|
||||
* 3. Monorepo root context, using the same order, as a per-file fallback
|
||||
* 4. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user
|
||||
* escape hatch, only consulted when defaults are empty
|
||||
* 4. cwd as a "nothing found" default
|
||||
* 5. Active project root as a "nothing found" default
|
||||
*
|
||||
* `resolveContextDir()` and `loadContext()` are also exported for the
|
||||
* server-side scripts (live.mjs, live-server.mjs) that need the structured
|
||||
@@ -19,10 +20,25 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseTargetOptions } from './lib/target-args.mjs';
|
||||
|
||||
const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md'];
|
||||
const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md'];
|
||||
const FALLBACK_DIRS = ['.agents/context', 'docs'];
|
||||
const MONOREPO_MARKER_FILES = ['pnpm-workspace.yaml', 'turbo.json', 'nx.json', 'lerna.json'];
|
||||
const MONOREPO_FALLBACK_PROJECT_DIRS = ['apps', 'packages'];
|
||||
const WORKSPACE_DISCOVERY_IGNORED_DIRS = new Set([
|
||||
'node_modules',
|
||||
'.git',
|
||||
'dist',
|
||||
'build',
|
||||
'.next',
|
||||
'.nuxt',
|
||||
'.svelte-kit',
|
||||
'.turbo',
|
||||
'.cache',
|
||||
'coverage',
|
||||
]);
|
||||
|
||||
// ─── Update check ──────────────────────────────────────────────────────────
|
||||
// Piggyback a lightweight skill-version check on the once-per-session boot.
|
||||
@@ -38,41 +54,600 @@ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // throttle the network poll to o
|
||||
const RENOTIFY_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000; // don't re-surface the same version for a week
|
||||
const FETCH_TIMEOUT_MS = 1200;
|
||||
|
||||
export function resolveContextDir(cwd = process.cwd()) {
|
||||
if (firstExisting(cwd, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return cwd;
|
||||
}
|
||||
for (const rel of FALLBACK_DIRS) {
|
||||
const candidate = path.resolve(cwd, rel);
|
||||
if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
const envDir = process.env.IMPECCABLE_CONTEXT_DIR;
|
||||
if (envDir && envDir.trim()) {
|
||||
const trimmed = envDir.trim();
|
||||
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
||||
}
|
||||
return cwd;
|
||||
export function resolveContextDir(cwd = process.cwd(), options = {}) {
|
||||
return resolveContext(cwd, options).contextDir;
|
||||
}
|
||||
|
||||
export function loadContext(cwd = process.cwd()) {
|
||||
const contextDir = resolveContextDir(cwd);
|
||||
const productPath = firstExisting(contextDir, PRODUCT_NAMES);
|
||||
const designPath = firstExisting(contextDir, DESIGN_NAMES);
|
||||
export function loadContext(cwd = process.cwd(), options = {}) {
|
||||
const resolved = resolveContext(cwd, options);
|
||||
const absCwd = path.resolve(cwd);
|
||||
const productPath = resolved.productPath;
|
||||
const designPath = resolved.designPath;
|
||||
const product = productPath ? safeRead(productPath) : null;
|
||||
const design = designPath ? safeRead(designPath) : null;
|
||||
return {
|
||||
hasProduct: !!product,
|
||||
product,
|
||||
productPath: productPath ? path.relative(cwd, productPath) : null,
|
||||
productPath: productPath ? path.relative(absCwd, productPath) : null,
|
||||
hasDesign: !!design,
|
||||
design,
|
||||
designPath: designPath ? path.relative(cwd, designPath) : null,
|
||||
contextDir,
|
||||
designPath: designPath ? path.relative(absCwd, designPath) : null,
|
||||
contextDir: resolved.contextDir,
|
||||
productContextDir: productPath ? path.dirname(productPath) : null,
|
||||
designContextDir: designPath ? path.dirname(designPath) : null,
|
||||
projectRoot: resolved.projectRoot,
|
||||
repoRoot: resolved.repoRoot,
|
||||
isMonorepo: resolved.isMonorepo,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveContext(cwd = process.cwd(), options = {}) {
|
||||
const absCwd = path.resolve(cwd);
|
||||
const project = resolveProject(absCwd, options);
|
||||
const projectContextDir = resolveLocalContextDir(project.projectRoot);
|
||||
const rootContextDir = project.isMonorepo && project.repoRoot !== project.projectRoot
|
||||
? resolveLocalContextDir(project.repoRoot)
|
||||
: null;
|
||||
|
||||
let productPath =
|
||||
(projectContextDir ? firstExisting(projectContextDir, PRODUCT_NAMES) : null)
|
||||
|| (rootContextDir ? firstExisting(rootContextDir, PRODUCT_NAMES) : null);
|
||||
let designPath =
|
||||
(projectContextDir ? firstExisting(projectContextDir, DESIGN_NAMES) : null)
|
||||
|| (rootContextDir ? firstExisting(rootContextDir, DESIGN_NAMES) : null);
|
||||
|
||||
let envContextDir = null;
|
||||
if (!productPath && !designPath) {
|
||||
envContextDir = resolveEnvContextDir(absCwd);
|
||||
if (envContextDir) {
|
||||
productPath = firstExisting(envContextDir, PRODUCT_NAMES);
|
||||
designPath = firstExisting(envContextDir, DESIGN_NAMES);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
contextDir: productPath
|
||||
? path.dirname(productPath)
|
||||
: designPath
|
||||
? path.dirname(designPath)
|
||||
: envContextDir || project.projectRoot,
|
||||
productPath,
|
||||
designPath,
|
||||
projectRoot: project.projectRoot,
|
||||
repoRoot: project.repoRoot,
|
||||
isMonorepo: project.isMonorepo,
|
||||
targetDir: project.targetDir,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveProjectRoot(cwd = process.cwd(), options = {}) {
|
||||
return resolveProject(cwd, options).projectRoot;
|
||||
}
|
||||
|
||||
export function resolveTargetSelection(cwd = process.cwd(), options = {}) {
|
||||
if (hasTargetOption(options)) return null;
|
||||
const project = resolveProject(cwd);
|
||||
if (
|
||||
!project.isMonorepo
|
||||
|| !project.projectRoot
|
||||
|| !project.repoRoot
|
||||
|| path.resolve(project.projectRoot) !== path.resolve(project.repoRoot)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
targetPath: null,
|
||||
projectRoot: project.projectRoot,
|
||||
repoRoot: project.repoRoot,
|
||||
targetCandidates: discoverTargetCandidates(project.repoRoot),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveProject(cwd = process.cwd(), options = {}) {
|
||||
const absCwd = path.resolve(cwd);
|
||||
const targetDir = resolveTargetDir(absCwd, options);
|
||||
let repoRoot = findMonorepoRoot(targetDir);
|
||||
if (!repoRoot && targetDir !== absCwd) {
|
||||
const cwdRepoRoot = findMonorepoRoot(absCwd);
|
||||
if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {
|
||||
repoRoot = cwdRepoRoot;
|
||||
}
|
||||
}
|
||||
if (!repoRoot) {
|
||||
return {
|
||||
targetDir,
|
||||
projectRoot: absCwd,
|
||||
repoRoot: absCwd,
|
||||
isMonorepo: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
targetDir,
|
||||
projectRoot: resolveWorkspaceProjectRoot(repoRoot, targetDir) || repoRoot,
|
||||
repoRoot,
|
||||
isMonorepo: true,
|
||||
};
|
||||
}
|
||||
|
||||
function isPathInside(candidate, root) {
|
||||
const rel = path.relative(root, candidate);
|
||||
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
|
||||
}
|
||||
|
||||
function resolveLocalContextDir(root) {
|
||||
if (firstExisting(root, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return root;
|
||||
}
|
||||
for (const rel of FALLBACK_DIRS) {
|
||||
const candidate = path.resolve(root, rel);
|
||||
if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveEnvContextDir(cwd) {
|
||||
const envDir = process.env.IMPECCABLE_CONTEXT_DIR;
|
||||
if (!envDir || !envDir.trim()) return null;
|
||||
const trimmed = envDir.trim();
|
||||
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
||||
}
|
||||
|
||||
function resolveTargetDir(cwd, options = {}) {
|
||||
const targetPath = options && typeof options === 'object' ? options.targetPath : null;
|
||||
if (!targetPath || !String(targetPath).trim()) return cwd;
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
try {
|
||||
const stat = fs.statSync(abs);
|
||||
return stat.isDirectory() ? abs : path.dirname(abs);
|
||||
} catch {
|
||||
return path.extname(abs) ? path.dirname(abs) : abs;
|
||||
}
|
||||
}
|
||||
|
||||
function findMonorepoRoot(startDir) {
|
||||
let dir = path.resolve(startDir);
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
while (true) {
|
||||
if (dir === homeDir) return null;
|
||||
if (isMonorepoRoot(dir)) return dir;
|
||||
if (hasGitBoundary(dir)) return null;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function isMonorepoRoot(dir) {
|
||||
if (readWorkspacePatterns(dir).some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) return true;
|
||||
if (!MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(dir, file)))) return false;
|
||||
return hasFallbackWorkspaceChildren(dir);
|
||||
}
|
||||
|
||||
function hasGitBoundary(dir) {
|
||||
return fs.existsSync(path.join(dir, '.git'));
|
||||
}
|
||||
|
||||
function hasFallbackWorkspaceChildren(dir) {
|
||||
for (const name of MONOREPO_FALLBACK_PROJECT_DIRS) {
|
||||
const base = path.join(dir, name);
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(base, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (entries.some((entry) => entry.isDirectory() && !isIgnoredWorkspaceDiscoveryDir(entry.name))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function discoverTargetCandidates(repoRoot) {
|
||||
const roots = new Map();
|
||||
for (const pattern of readWorkspacePatterns(repoRoot)) {
|
||||
for (const root of discoverRootsForPattern(repoRoot, pattern)) {
|
||||
roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root);
|
||||
}
|
||||
}
|
||||
if (MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(repoRoot, file)))) {
|
||||
for (const name of MONOREPO_FALLBACK_PROJECT_DIRS) {
|
||||
const base = path.join(repoRoot, name);
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(base, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
||||
const root = path.join(base, entry.name);
|
||||
roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...roots.entries()]
|
||||
.filter(([rel]) => rel && !rel.startsWith('..'))
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([rel, root]) => {
|
||||
const targetExample = findTargetExample(repoRoot, root);
|
||||
return {
|
||||
name: path.basename(root),
|
||||
path: rel,
|
||||
targetExample,
|
||||
...resolveCandidateContextSummary(repoRoot, root, targetExample),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function resolveCandidateContextSummary(repoRoot, projectRoot, targetPath) {
|
||||
const ctx = resolveContext(repoRoot, { targetPath });
|
||||
return {
|
||||
productStatus: contextSourceStatus(ctx.productPath, repoRoot, projectRoot),
|
||||
productPath: contextSourcePath(ctx.productPath, repoRoot),
|
||||
designStatus: contextSourceStatus(ctx.designPath, repoRoot, projectRoot),
|
||||
designPath: contextSourcePath(ctx.designPath, repoRoot),
|
||||
};
|
||||
}
|
||||
|
||||
function contextSourceStatus(filePath, repoRoot, projectRoot) {
|
||||
if (!filePath) return 'missing';
|
||||
const absPath = path.resolve(filePath);
|
||||
const absProjectRoot = path.resolve(projectRoot);
|
||||
const absRepoRoot = path.resolve(repoRoot);
|
||||
if (isPathInsideOrEqual(absPath, absProjectRoot)) {
|
||||
return path.dirname(absPath) === absProjectRoot ? 'child' : 'fallback';
|
||||
}
|
||||
if (absProjectRoot !== absRepoRoot && isPathInsideOrEqual(absPath, absRepoRoot)) {
|
||||
return 'inherited';
|
||||
}
|
||||
return 'fallback';
|
||||
}
|
||||
|
||||
function contextSourcePath(filePath, repoRoot) {
|
||||
if (!filePath) return null;
|
||||
const rel = path.relative(repoRoot, filePath);
|
||||
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {
|
||||
return rel.split(path.sep).join('/');
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function discoverRootsForPattern(repoRoot, rawPattern) {
|
||||
const pattern = normalizeWorkspacePattern(rawPattern);
|
||||
if (!pattern || pattern.startsWith('!')) return [];
|
||||
const segments = pattern.split('/').filter(Boolean);
|
||||
if (!segments.length) return [];
|
||||
const firstGlobIndex = segments.findIndex((segment) => segment.includes('*'));
|
||||
const literalPrefix = firstGlobIndex === -1 ? segments : segments.slice(0, firstGlobIndex);
|
||||
const base = path.join(repoRoot, ...literalPrefix);
|
||||
if (!fs.existsSync(base)) return [];
|
||||
if (segments.includes('**')) {
|
||||
const packageRoots = [];
|
||||
walkDirs(base, (dir) => {
|
||||
if (dir !== base && isCandidateProjectRoot(dir)) packageRoots.push(dir);
|
||||
});
|
||||
if (packageRoots.length) return packageRoots;
|
||||
return directChildDirs(base);
|
||||
}
|
||||
return expandSimplePattern(repoRoot, segments);
|
||||
}
|
||||
|
||||
function expandSimplePattern(repoRoot, patternSegments, index = 0, current = repoRoot) {
|
||||
if (index >= patternSegments.length) return fs.existsSync(current) ? [current] : [];
|
||||
const segment = patternSegments[index];
|
||||
if (!segment.includes('*')) {
|
||||
return expandSimplePattern(repoRoot, patternSegments, index + 1, path.join(current, segment));
|
||||
}
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(current, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const roots = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
||||
if (!segmentMatches(segment, entry.name)) continue;
|
||||
roots.push(...expandSimplePattern(repoRoot, patternSegments, index + 1, path.join(current, entry.name)));
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
function directChildDirs(dir) {
|
||||
try {
|
||||
return fs.readdirSync(dir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() && !isIgnoredWorkspaceDiscoveryDir(entry.name))
|
||||
.map((entry) => path.join(dir, entry.name));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function walkDirs(root, visit) {
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(root, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
||||
const dir = path.join(root, entry.name);
|
||||
visit(dir);
|
||||
walkDirs(dir, visit);
|
||||
}
|
||||
}
|
||||
|
||||
function isCandidateProjectRoot(dir) {
|
||||
return !!(
|
||||
fs.existsSync(path.join(dir, 'package.json'))
|
||||
|| firstExisting(dir, [...PRODUCT_NAMES, ...DESIGN_NAMES])
|
||||
|| fs.existsSync(path.join(dir, 'src'))
|
||||
|| fs.existsSync(path.join(dir, 'app'))
|
||||
|| fs.existsSync(path.join(dir, 'pages'))
|
||||
|| fs.existsSync(path.join(dir, 'public'))
|
||||
);
|
||||
}
|
||||
|
||||
function isIgnoredWorkspaceDiscoveryDir(name) {
|
||||
return name.startsWith('.') || WORKSPACE_DISCOVERY_IGNORED_DIRS.has(name);
|
||||
}
|
||||
|
||||
function findTargetExample(repoRoot, projectRoot) {
|
||||
const examples = [
|
||||
'src/App.jsx',
|
||||
'src/App.tsx',
|
||||
'src/main.jsx',
|
||||
'src/main.tsx',
|
||||
'src/index.jsx',
|
||||
'src/index.ts',
|
||||
'app/page.tsx',
|
||||
'pages/index.tsx',
|
||||
'public/index.html',
|
||||
];
|
||||
for (const rel of examples) {
|
||||
const abs = path.join(projectRoot, rel);
|
||||
if (fs.existsSync(abs)) return path.relative(repoRoot, abs).split(path.sep).join('/');
|
||||
}
|
||||
return path.relative(repoRoot, projectRoot).split(path.sep).join('/');
|
||||
}
|
||||
|
||||
function resolveWorkspaceProjectRoot(repoRoot, targetDir) {
|
||||
const rel = path.relative(repoRoot, targetDir);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return repoRoot;
|
||||
const relSegments = rel.split(path.sep).filter(Boolean);
|
||||
const patterns = readWorkspacePatterns(repoRoot);
|
||||
const excluded = isExcludedByWorkspacePattern(relSegments, patterns);
|
||||
if (!excluded) {
|
||||
for (const pattern of patterns) {
|
||||
const projectRoot = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern);
|
||||
if (projectRoot) return projectRoot;
|
||||
}
|
||||
}
|
||||
if (excluded) return repoRoot;
|
||||
if (
|
||||
relSegments.length >= 2
|
||||
&& MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0])
|
||||
) {
|
||||
return path.join(repoRoot, relSegments[0], relSegments[1]);
|
||||
}
|
||||
const nearest = nearestProjectLikeRoot(repoRoot, targetDir);
|
||||
if (nearest) return nearest;
|
||||
return repoRoot;
|
||||
}
|
||||
|
||||
function isExcludedByWorkspacePattern(relSegments, patterns) {
|
||||
return patterns.some((rawPattern) => {
|
||||
const pattern = normalizeWorkspacePattern(rawPattern);
|
||||
if (!pattern.startsWith('!')) return false;
|
||||
return workspacePatternMatchesRel(pattern.slice(1), relSegments);
|
||||
});
|
||||
}
|
||||
|
||||
function nearestProjectLikeRoot(repoRoot, targetDir) {
|
||||
let dir = path.resolve(targetDir);
|
||||
const stop = path.resolve(repoRoot);
|
||||
while (dir && dir !== stop) {
|
||||
if (
|
||||
firstExisting(dir, [...PRODUCT_NAMES, ...DESIGN_NAMES])
|
||||
|| fs.existsSync(path.join(dir, 'package.json'))
|
||||
) {
|
||||
return dir;
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function nearestPackageRootBetween(repoRoot, targetDir, stopDir) {
|
||||
let dir = path.resolve(targetDir);
|
||||
const stop = path.resolve(stopDir || repoRoot);
|
||||
const root = path.resolve(repoRoot);
|
||||
while (dir && dir !== stop && isPathInsideOrEqual(dir, root)) {
|
||||
if (fs.existsSync(path.join(dir, 'package.json'))) return dir;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPathInsideOrEqual(candidate, root) {
|
||||
return path.resolve(candidate) === path.resolve(root) || isPathInside(candidate, root);
|
||||
}
|
||||
|
||||
function workspacePatternMatchesRel(pattern, relSegments) {
|
||||
const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return false;
|
||||
if (patternSegments.includes('**')) {
|
||||
const firstGlobIndex = patternSegments.findIndex((segment) => segment.includes('*'));
|
||||
const literalPrefix = firstGlobIndex === -1
|
||||
? patternSegments
|
||||
: patternSegments.slice(0, firstGlobIndex);
|
||||
if (relSegments.length < literalPrefix.length + 1) return false;
|
||||
for (let i = 0; i < literalPrefix.length; i++) {
|
||||
if (!segmentMatches(literalPrefix[i], relSegments[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (relSegments.length < patternSegments.length) return false;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function readWorkspacePatterns(repoRoot) {
|
||||
return [
|
||||
...readPackageWorkspaces(repoRoot),
|
||||
...readPnpmWorkspaces(repoRoot),
|
||||
...readLernaWorkspaces(repoRoot),
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
function readPackageWorkspaces(repoRoot) {
|
||||
const pkg = readJson(path.join(repoRoot, 'package.json'));
|
||||
const workspaces = pkg?.workspaces;
|
||||
if (Array.isArray(workspaces)) return workspaces;
|
||||
if (Array.isArray(workspaces?.packages)) return workspaces.packages;
|
||||
return [];
|
||||
}
|
||||
|
||||
function readLernaWorkspaces(repoRoot) {
|
||||
const lerna = readJson(path.join(repoRoot, 'lerna.json'));
|
||||
return Array.isArray(lerna?.packages) ? lerna.packages : [];
|
||||
}
|
||||
|
||||
function readPnpmWorkspaces(repoRoot) {
|
||||
try {
|
||||
const body = fs.readFileSync(path.join(repoRoot, 'pnpm-workspace.yaml'), 'utf-8');
|
||||
const patterns = [];
|
||||
let inPackages = false;
|
||||
for (const line of body.split(/\r?\n/)) {
|
||||
const trimmed = stripYamlInlineComment(line).trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const flowMatch = trimmed.match(/^packages:\s*\[(.*)\]\s*$/);
|
||||
if (flowMatch) {
|
||||
patterns.push(...parseYamlFlowList(flowMatch[1]));
|
||||
inPackages = false;
|
||||
continue;
|
||||
}
|
||||
if (/^packages:\s*$/.test(trimmed)) {
|
||||
inPackages = true;
|
||||
continue;
|
||||
}
|
||||
if (inPackages && /^[A-Za-z0-9_-]+:\s*/.test(trimmed)) break;
|
||||
if (inPackages) {
|
||||
const match = trimmed.match(/^-\s*(.+)$/);
|
||||
if (match) patterns.push(unquoteYamlValue(match[1]));
|
||||
}
|
||||
}
|
||||
return patterns;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function stripYamlInlineComment(line) {
|
||||
let quote = null;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
if ((ch === '"' || ch === "'") && line[i - 1] !== '\\') {
|
||||
quote = quote === ch ? null : quote || ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === '#' && !quote) return line.slice(0, i);
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
function parseYamlFlowList(body) {
|
||||
const items = [];
|
||||
let quote = null;
|
||||
let current = '';
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
const ch = body[i];
|
||||
if ((ch === '"' || ch === "'") && body[i - 1] !== '\\') {
|
||||
quote = quote === ch ? null : quote || ch;
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === ',' && !quote) {
|
||||
const value = unquoteYamlValue(current);
|
||||
if (value) items.push(value);
|
||||
current = '';
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
}
|
||||
const value = unquoteYamlValue(current);
|
||||
if (value) items.push(value);
|
||||
return items;
|
||||
}
|
||||
|
||||
function unquoteYamlValue(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '');
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function projectRootFromWorkspacePattern(repoRoot, relSegments, rawPattern) {
|
||||
const pattern = normalizeWorkspacePattern(rawPattern);
|
||||
if (!pattern || pattern.startsWith('!')) return null;
|
||||
const patternSegments = pattern.split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return null;
|
||||
if (patternSegments.includes('**')) {
|
||||
return projectRootFromDoubleStarPattern(repoRoot, relSegments, patternSegments);
|
||||
}
|
||||
if (relSegments.length < patternSegments.length) return null;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return null;
|
||||
}
|
||||
return path.join(repoRoot, ...relSegments.slice(0, patternSegments.length));
|
||||
}
|
||||
|
||||
function projectRootFromDoubleStarPattern(repoRoot, relSegments, patternSegments) {
|
||||
const firstGlobIndex = patternSegments.findIndex((segment) => segment.includes('*'));
|
||||
const literalPrefix = firstGlobIndex === -1
|
||||
? patternSegments
|
||||
: patternSegments.slice(0, firstGlobIndex);
|
||||
if (relSegments.length < literalPrefix.length + 1) return null;
|
||||
for (let i = 0; i < literalPrefix.length; i++) {
|
||||
if (!segmentMatches(literalPrefix[i], relSegments[i])) return null;
|
||||
}
|
||||
const prefixDir = path.join(repoRoot, ...literalPrefix);
|
||||
const targetDir = path.join(repoRoot, ...relSegments);
|
||||
const packageRoot = nearestPackageRootBetween(repoRoot, targetDir, prefixDir);
|
||||
if (packageRoot) return packageRoot;
|
||||
return path.join(repoRoot, ...relSegments.slice(0, literalPrefix.length + 1));
|
||||
}
|
||||
|
||||
function normalizeWorkspacePattern(pattern) {
|
||||
return String(pattern || '')
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '')
|
||||
.replace(/^\.\//, '')
|
||||
.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function segmentMatches(patternSegment, relSegment) {
|
||||
if (patternSegment === '*') return true;
|
||||
if (!patternSegment.includes('*')) return patternSegment === relSegment;
|
||||
const re = new RegExp(`^${escapeRegExp(patternSegment).replace(/\\\*/g, '[^/]*')}$`);
|
||||
return re.test(relSegment);
|
||||
}
|
||||
|
||||
function firstExisting(dir, names) {
|
||||
for (const name of names) {
|
||||
const abs = path.join(dir, name);
|
||||
@@ -89,6 +664,10 @@ function safeRead(p) {
|
||||
}
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the register (`brand` or `product`) out of PRODUCT.md by looking
|
||||
* for a `## Register` section and reading the first non-empty line that
|
||||
@@ -233,7 +812,24 @@ async function computeUpdateDirective(now = Date.now()) {
|
||||
}
|
||||
|
||||
async function cli() {
|
||||
const ctx = loadContext(process.cwd());
|
||||
let cliOptions;
|
||||
try {
|
||||
cliOptions = parseCliOptions(process.argv.slice(2));
|
||||
} catch (err) {
|
||||
if (err?.name === 'TargetArgError') {
|
||||
process.stderr.write(`${err.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const targetProvided = hasTargetOption(cliOptions);
|
||||
const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null;
|
||||
const selection = resolveTargetSelection(process.cwd(), cliOptions);
|
||||
if (selection) {
|
||||
process.stdout.write(buildTargetSelectionDirective(selection) + '\n');
|
||||
process.exit(0);
|
||||
}
|
||||
const ctx = loadContext(process.cwd(), cliOptions);
|
||||
const updateDirective = await computeUpdateDirective();
|
||||
|
||||
if (!ctx.hasProduct) {
|
||||
@@ -244,6 +840,10 @@ async function cli() {
|
||||
'Stop the current task, load reference/init.md, and follow its ' +
|
||||
'instructions to write PRODUCT.md before resuming.',
|
||||
];
|
||||
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
|
||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||
parts.push(buildMissingTargetDirective());
|
||||
}
|
||||
if (updateDirective) parts.push(updateDirective);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
process.exit(0);
|
||||
@@ -252,6 +852,10 @@ async function cli() {
|
||||
if (ctx.hasDesign) {
|
||||
parts.push(`# DESIGN.md\n\n${ctx.design.trim()}`);
|
||||
}
|
||||
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
|
||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||
parts.push(buildMissingTargetDirective());
|
||||
}
|
||||
const register = extractRegister(ctx.product);
|
||||
const next = register
|
||||
? `NEXT STEP: This project's register is \`${register}\`. You MUST now read \`reference/${register}.md\` before producing any design output.`
|
||||
@@ -261,6 +865,60 @@ async function cli() {
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
}
|
||||
|
||||
function parseCliOptions(args) {
|
||||
return parseTargetOptions(args, { strict: true });
|
||||
}
|
||||
|
||||
function hasTargetOption(options) {
|
||||
return !!(options && typeof options.targetPath === 'string' && options.targetPath.trim());
|
||||
}
|
||||
|
||||
function pathExistsForTarget(cwd, targetPath) {
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
return fs.existsSync(abs);
|
||||
}
|
||||
|
||||
function buildResolvedContextDirective(ctx, options, { targetExists = null } = {}) {
|
||||
const targetPath = hasTargetOption(options) ? options.targetPath : null;
|
||||
return `RESOLVED_CONTEXT:\n${JSON.stringify({
|
||||
targetPath,
|
||||
...(targetPath ? { targetExists } : {}),
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
productPath: ctx.productPath,
|
||||
designPath: ctx.designPath,
|
||||
}, null, 2)}`;
|
||||
}
|
||||
|
||||
function shouldWarnMissingTarget(ctx, targetProvided, targetExists = null) {
|
||||
if (ctx.isMonorepo && targetProvided && targetExists === false) return true;
|
||||
return !!(
|
||||
ctx.isMonorepo
|
||||
&& (!targetProvided || targetExists === false)
|
||||
&& ctx.projectRoot
|
||||
&& ctx.repoRoot
|
||||
&& path.resolve(ctx.projectRoot) === path.resolve(ctx.repoRoot)
|
||||
);
|
||||
}
|
||||
|
||||
function buildMissingTargetDirective() {
|
||||
const script = process.argv[1] || 'context.mjs';
|
||||
return (
|
||||
'MONOREPO_TARGET_REQUIRED: This is a monorepo and context.mjs ran without --target. ' +
|
||||
'If the user named a file, route, or child app, do not answer from this output. ' +
|
||||
`Rerun \`node ${script} --target <path>\` and answer from that run's RESOLVED_CONTEXT fields.`
|
||||
);
|
||||
}
|
||||
|
||||
function buildTargetSelectionDirective(selection) {
|
||||
return (
|
||||
`TARGET_SELECTION_REQUIRED:\n${JSON.stringify(selection, null, 2)}\n\n` +
|
||||
'Show each app with its productStatus/productPath and designStatus/designPath so the user can see child overrides, inherited root files, fallback files, or missing files before choosing. ' +
|
||||
'Ask the user which app Impeccable should use, then rerun Impeccable helper commands from that child app cwd using this same scripts directory. ' +
|
||||
'Use `--target <path>` only as a fallback when changing cwd is not possible, or when the user explicitly named a file/path.'
|
||||
);
|
||||
}
|
||||
|
||||
// Run cli() only when this module is the entry point. Compare realpaths
|
||||
// rather than endsWith(): a loose suffix match also fires for unrelated
|
||||
// scripts like `load-context.mjs`, and realpath tolerates symlinked
|
||||
|
||||
@@ -1,50 +1,52 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { resolveProjectRoot } from '../context.mjs';
|
||||
|
||||
export const IMPECCABLE_DIR = '.impeccable';
|
||||
export const LIVE_DIR = 'live';
|
||||
export const CRITIQUE_DIR = 'critique';
|
||||
|
||||
export function getImpeccableDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, IMPECCABLE_DIR);
|
||||
export function getImpeccableDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), IMPECCABLE_DIR);
|
||||
}
|
||||
|
||||
export function getDesignSidecarPath(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), 'design.json');
|
||||
export function getDesignSidecarPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), 'design.json');
|
||||
}
|
||||
|
||||
export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd) {
|
||||
export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd, options = {}) {
|
||||
const projectRoot = resolveProjectRoot(cwd, options);
|
||||
const candidates = [
|
||||
getDesignSidecarPath(cwd),
|
||||
path.join(cwd, 'DESIGN.json'),
|
||||
getDesignSidecarPath(cwd, options),
|
||||
path.join(projectRoot, 'DESIGN.json'),
|
||||
];
|
||||
const contextLegacy = path.join(contextDir, 'DESIGN.json');
|
||||
if (!candidates.includes(contextLegacy)) candidates.push(contextLegacy);
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) {
|
||||
return firstExisting(getDesignSidecarCandidates(cwd, contextDir));
|
||||
export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd, options = {}) {
|
||||
return firstExisting(getDesignSidecarCandidates(cwd, contextDir, options));
|
||||
}
|
||||
|
||||
export function getLiveDir(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), LIVE_DIR);
|
||||
export function getLiveDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), LIVE_DIR);
|
||||
}
|
||||
|
||||
export function getLiveConfigPath(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'config.json');
|
||||
export function getLiveConfigPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'config.json');
|
||||
}
|
||||
|
||||
export function getLegacyLiveConfigPath(scriptsDir) {
|
||||
return path.join(scriptsDir, 'config.json');
|
||||
}
|
||||
|
||||
export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env } = {}) {
|
||||
export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env, targetPath } = {}) {
|
||||
if (env.IMPECCABLE_LIVE_CONFIG && env.IMPECCABLE_LIVE_CONFIG.trim()) {
|
||||
const configured = env.IMPECCABLE_LIVE_CONFIG.trim();
|
||||
return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured);
|
||||
}
|
||||
const primary = getLiveConfigPath(cwd);
|
||||
const primary = getLiveConfigPath(cwd, { targetPath });
|
||||
if (fs.existsSync(primary)) return primary;
|
||||
if (scriptsDir) {
|
||||
const legacy = getLegacyLiveConfigPath(scriptsDir);
|
||||
@@ -53,16 +55,16 @@ export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = p
|
||||
return primary;
|
||||
}
|
||||
|
||||
export function getLiveServerPath(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'server.json');
|
||||
export function getLiveServerPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'server.json');
|
||||
}
|
||||
|
||||
export function getLegacyLiveServerPath(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live.json');
|
||||
export function getLegacyLiveServerPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live.json');
|
||||
}
|
||||
|
||||
export function readLiveServerInfo(cwd = process.cwd()) {
|
||||
for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) {
|
||||
export function readLiveServerInfo(cwd = process.cwd(), options = {}) {
|
||||
for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) {
|
||||
try {
|
||||
const info = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
if (info && typeof info.pid === 'number' && !isLiveServerPidReachable(info.pid)) {
|
||||
@@ -88,37 +90,37 @@ export function isLiveServerPidReachable(pid) {
|
||||
}
|
||||
}
|
||||
|
||||
export function writeLiveServerInfo(cwd = process.cwd(), info) {
|
||||
const filePath = getLiveServerPath(cwd);
|
||||
export function writeLiveServerInfo(cwd = process.cwd(), info, options = {}) {
|
||||
const filePath = getLiveServerPath(cwd, options);
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(info));
|
||||
return filePath;
|
||||
}
|
||||
|
||||
export function removeLiveServerInfo(cwd = process.cwd()) {
|
||||
for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) {
|
||||
export function removeLiveServerInfo(cwd = process.cwd(), options = {}) {
|
||||
for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) {
|
||||
try { fs.unlinkSync(filePath); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
export function getLiveSessionsDir(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'sessions');
|
||||
export function getLiveSessionsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'sessions');
|
||||
}
|
||||
|
||||
export function getLegacyLiveSessionsDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live', 'sessions');
|
||||
export function getLegacyLiveSessionsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'sessions');
|
||||
}
|
||||
|
||||
export function getLiveAnnotationsDir(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'annotations');
|
||||
export function getLiveAnnotationsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'annotations');
|
||||
}
|
||||
|
||||
export function getCritiqueDir(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), CRITIQUE_DIR);
|
||||
export function getCritiqueDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), CRITIQUE_DIR);
|
||||
}
|
||||
|
||||
export function getLegacyLiveAnnotationsDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live', 'annotations');
|
||||
export function getLegacyLiveAnnotationsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'annotations');
|
||||
}
|
||||
|
||||
function firstExisting(paths) {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
class TargetArgError extends Error {
|
||||
constructor(message, code) {
|
||||
super(message);
|
||||
this.name = 'TargetArgError';
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseTargetPath(args = [], { strict = false } = {}) {
|
||||
let targetPath = null;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = String(args[i]);
|
||||
if (arg === '--target' || arg === '-t') {
|
||||
const next = args[i + 1];
|
||||
if (next && !String(next).startsWith('-')) {
|
||||
targetPath = String(next);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (strict) {
|
||||
throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith('--target=')) {
|
||||
const value = arg.slice('--target='.length);
|
||||
if (value) {
|
||||
targetPath = value;
|
||||
continue;
|
||||
}
|
||||
if (strict) {
|
||||
throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING');
|
||||
}
|
||||
}
|
||||
}
|
||||
return targetPath;
|
||||
}
|
||||
|
||||
export function parseTargetOptions(args = [], options = {}) {
|
||||
const targetPath = parseTargetPath(args, options);
|
||||
return targetPath ? { targetPath } : {};
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import path from 'node:path';
|
||||
import net from 'node:net';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseDesignMd } from './lib/design-parser.mjs';
|
||||
import { resolveContextDir } from './context.mjs';
|
||||
import { loadContext } from './context.mjs';
|
||||
import {
|
||||
assembleLiveBrowserScript,
|
||||
assertLiveBrowserScriptParts,
|
||||
@@ -55,7 +55,11 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
// PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
|
||||
// DESIGN sidecar is project-local at .impeccable/design.json, with legacy
|
||||
// DESIGN.json fallback for existing projects.
|
||||
const CONTEXT_DIR = resolveContextDir(process.cwd());
|
||||
const PROJECT_CONTEXT = loadContext(process.cwd());
|
||||
const CONTEXT_DIR = PROJECT_CONTEXT.contextDir;
|
||||
const DESIGN_MD_PATH = PROJECT_CONTEXT.designPath
|
||||
? path.resolve(process.cwd(), PROJECT_CONTEXT.designPath)
|
||||
: null;
|
||||
const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway
|
||||
const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s
|
||||
|
||||
@@ -371,10 +375,7 @@ function hasProjectContext() {
|
||||
// PRODUCT.md carries brand voice / anti-references — that's what determines
|
||||
// whether variants are brand-aware. DESIGN.md (visual tokens) is a separate
|
||||
// concern, surfaced by the design panel's own empty state.
|
||||
try {
|
||||
fs.accessSync(path.join(CONTEXT_DIR, 'PRODUCT.md'), fs.constants.R_OK);
|
||||
return true;
|
||||
} catch { return false; }
|
||||
return !!PROJECT_CONTEXT.hasProduct;
|
||||
}
|
||||
|
||||
function statOrNull(filePath) {
|
||||
@@ -549,8 +550,8 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
const token = url.searchParams.get('token');
|
||||
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
|
||||
|
||||
const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md');
|
||||
const jsonPath = resolveDesignSidecarPath(process.cwd(), CONTEXT_DIR) || getDesignSidecarPath(process.cwd());
|
||||
const mdPath = DESIGN_MD_PATH;
|
||||
const jsonPath = resolveDesignSidecarPath(process.cwd(), PROJECT_CONTEXT.designContextDir || CONTEXT_DIR) || getDesignSidecarPath(process.cwd());
|
||||
const mdStat = statOrNull(mdPath);
|
||||
const jsonStat = statOrNull(jsonPath);
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import path from 'node:path';
|
||||
import { resolveProjectRoot } from './context.mjs';
|
||||
import { parseTargetPath } from './lib/target-args.mjs';
|
||||
|
||||
export function resolveLiveTarget(cwd = process.cwd(), args = []) {
|
||||
const originalCwd = path.resolve(cwd);
|
||||
let targetPath = null;
|
||||
try {
|
||||
targetPath = parseTargetPath(args, { strict: true });
|
||||
} catch (err) {
|
||||
if (err?.name === 'TargetArgError') {
|
||||
process.stderr.write(`${err.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const absoluteTargetPath = targetPath
|
||||
? path.isAbsolute(targetPath) ? targetPath : path.resolve(originalCwd, targetPath)
|
||||
: null;
|
||||
const projectRoot = targetPath
|
||||
? resolveProjectRoot(originalCwd, { targetPath: absoluteTargetPath })
|
||||
: originalCwd;
|
||||
return {
|
||||
originalCwd,
|
||||
projectRoot,
|
||||
targetPath,
|
||||
absoluteTargetPath,
|
||||
targetOptions: absoluteTargetPath ? { targetPath: absoluteTargetPath } : {},
|
||||
};
|
||||
}
|
||||
@@ -21,14 +21,16 @@ import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadContext } from './context.mjs';
|
||||
import { loadContext, resolveTargetSelection } from './context.mjs';
|
||||
import { resolveFiles } from './live-inject.mjs';
|
||||
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
|
||||
import { resolveLiveTarget } from './live-target.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
async function liveCli() {
|
||||
const args = process.argv.slice(2);
|
||||
const liveTarget = resolveLiveTarget(process.cwd(), args);
|
||||
|
||||
if (args.includes('--help') || args.includes('-h')) {
|
||||
console.log(`Usage: node live.mjs
|
||||
@@ -38,37 +40,78 @@ Prepare everything for live variant mode in a single command:
|
||||
- Starts (or reuses) the live server in the background
|
||||
- Injects the browser script tag
|
||||
- Reads PRODUCT.md / DESIGN.md for project context
|
||||
- In monorepos, choose a child app first; --target <path> is the fallback/manual path
|
||||
|
||||
On success, prints a JSON blob with:
|
||||
{ ok, serverPort, serverToken, pageFile, hasContext, context }
|
||||
{ ok, serverPort, serverToken, pageFiles, projectRoot, repoRoot, targetPath, productPath, designPath }
|
||||
|
||||
On target_selection_required, prints:
|
||||
{ ok: false, error: "target_selection_required", targetCandidates }
|
||||
|
||||
On config_missing, prints:
|
||||
{ ok: false, error: "config_missing", configPath, hint }
|
||||
|
||||
The agent should then:
|
||||
1. If config_missing, create the config and re-run this script
|
||||
2. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort)
|
||||
3. Enter the poll loop: node live-poll.mjs`);
|
||||
1. If target_selection_required, ask which app to use and rerun from that child cwd
|
||||
2. If config_missing, create the config and re-run this script
|
||||
3. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort)
|
||||
4. Enter the poll loop: node live-poll.mjs`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const targetSelection = resolveTargetSelection(liveTarget.originalCwd, liveTarget.targetOptions);
|
||||
if (targetSelection) {
|
||||
console.log(JSON.stringify({
|
||||
ok: false,
|
||||
error: 'target_selection_required',
|
||||
...targetSelection,
|
||||
hint: 'Ask the user which app Impeccable should use, then rerun live from that child app cwd. Use --target <path> only as a fallback or explicit path diagnostic.',
|
||||
}, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const ctx = loadContext(liveTarget.originalCwd, liveTarget.targetOptions);
|
||||
const activeCwd = ctx.projectRoot;
|
||||
const outputTargetPath = liveTarget.targetPath || null;
|
||||
|
||||
const missingContext = missingLiveContext(ctx);
|
||||
if (missingContext.length > 0) {
|
||||
console.log(JSON.stringify({
|
||||
ok: false,
|
||||
error: 'context_missing',
|
||||
missing: missingContext,
|
||||
nextCommand: missingContext.includes('PRODUCT.md') ? 'init' : 'document',
|
||||
targetPath: outputTargetPath,
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
productPath: ctx.productPath,
|
||||
designPath: ctx.designPath,
|
||||
}, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 1. Check config (fail fast if missing — no point starting anything else)
|
||||
const checkOut = runScript('live-inject.mjs', ['--check']);
|
||||
const checkOut = runScript('live-inject.mjs', ['--check'], { cwd: activeCwd });
|
||||
const checkResult = safeParse(checkOut);
|
||||
if (!checkResult || !checkResult.ok) {
|
||||
console.log(JSON.stringify(checkResult || { ok: false, error: 'check_failed', raw: checkOut }));
|
||||
console.log(JSON.stringify({
|
||||
...(checkResult || { ok: false, error: 'check_failed', raw: checkOut }),
|
||||
targetPath: outputTargetPath,
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
}));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 2. Start server (or reuse existing)
|
||||
const serverInfo = ensureServerRunning();
|
||||
const serverInfo = ensureServerRunning(activeCwd);
|
||||
if (!serverInfo) {
|
||||
console.log(JSON.stringify({ ok: false, error: 'server_start_failed' }));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 3. Inject the script tag at the current port
|
||||
const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)]);
|
||||
const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)], { cwd: activeCwd });
|
||||
const injectResult = safeParse(injectOut);
|
||||
if (!injectResult || !injectResult.ok) {
|
||||
console.log(JSON.stringify({
|
||||
@@ -80,22 +123,23 @@ The agent should then:
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 4. Load PRODUCT.md + DESIGN.md context.
|
||||
const ctx = loadContext(process.cwd());
|
||||
|
||||
// 5. Compute drift-heal: compare resolved inject targets against the
|
||||
// 4. 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);
|
||||
const resolvedFiles = resolveFiles(activeCwd, checkResult.config);
|
||||
const drift = scanForDrift(activeCwd, resolvedFiles, checkResult.config);
|
||||
|
||||
// 6. Emit everything the agent needs
|
||||
// 5. Emit everything the agent needs
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
serverPort: serverInfo.port,
|
||||
serverToken: serverInfo.token,
|
||||
pageFiles: resolvedFiles,
|
||||
liveConfigPath: checkResult.path,
|
||||
configDrift: drift,
|
||||
targetPath: outputTargetPath,
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
hasProduct: ctx.hasProduct,
|
||||
product: ctx.product,
|
||||
productPath: ctx.productPath,
|
||||
@@ -105,6 +149,13 @@ The agent should then:
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
function missingLiveContext(ctx) {
|
||||
const missing = [];
|
||||
if (!ctx.hasProduct) missing.push('PRODUCT.md');
|
||||
if (!ctx.hasDesign) missing.push('DESIGN.md');
|
||||
return missing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drift-heal scan. Walks the project for HTML files under common
|
||||
* page-source directories (public/, src/, app/, pages/) and reports any
|
||||
@@ -201,11 +252,11 @@ function globToRegex(pattern) {
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function runScript(name, args) {
|
||||
function runScript(name, args, options = {}) {
|
||||
const scriptPath = path.join(__dirname, name);
|
||||
const cmd = `node "${scriptPath}" ${args.map(a => `"${a}"`).join(' ')}`;
|
||||
try {
|
||||
return execSync(cmd, { encoding: 'utf-8', cwd: process.cwd(), timeout: 15_000 });
|
||||
return execSync(cmd, { encoding: 'utf-8', cwd: options.cwd || process.cwd(), timeout: 15_000 });
|
||||
} catch (err) {
|
||||
// execSync throws on non-zero exit; return stdout if any
|
||||
return err.stdout || err.message || '';
|
||||
@@ -219,10 +270,10 @@ function safeParse(out) {
|
||||
/**
|
||||
* Return { pid, port, token } for the running live server, starting one if needed.
|
||||
*/
|
||||
function ensureServerRunning() {
|
||||
function ensureServerRunning(cwd = process.cwd()) {
|
||||
// Try to reuse an existing server
|
||||
try {
|
||||
const existing = readLiveServerInfo(process.cwd())?.info;
|
||||
const existing = readLiveServerInfo(cwd)?.info;
|
||||
if (existing && existing.pid) {
|
||||
try {
|
||||
process.kill(existing.pid, 0); // throws if dead
|
||||
@@ -232,7 +283,7 @@ function ensureServerRunning() {
|
||||
} catch { /* no PID file */ }
|
||||
|
||||
// Start a new server
|
||||
const out = runScript('live-server.mjs', ['--background']);
|
||||
const out = runScript('live-server.mjs', ['--background'], { cwd });
|
||||
return safeParse(out);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ Designs and iterates production-grade frontend interfaces. Real working code, co
|
||||
|
||||
You MUST do these steps before proceeding:
|
||||
|
||||
1. Run `node .opencode/skills/impeccable/scripts/context.mjs` once per session. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task.
|
||||
1. Run `node .opencode/skills/impeccable/scripts/context.mjs` once per session. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .opencode/skills/impeccable/scripts/context.mjs --target <path>` instead. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task.
|
||||
2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read `reference/<command>.md` next. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects.
|
||||
3. Familiarize yourself with any existing design system, conventions, and components in the code. Read at least one project file (CSS / tokens / theme / a representative component or page). **Required even when you've loaded a sub-command reference in step 2.** Don't reinvent the wheel; use what's there when it works, branch out when the UX wins.
|
||||
4. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md.
|
||||
|
||||
@@ -8,7 +8,7 @@ A running dev server with hot module replacement (Vite, Next.js, Bun, etc.), OR
|
||||
|
||||
Execute in order. No step skipped, no step reordered.
|
||||
|
||||
1. `live.mjs`: boot.
|
||||
1. `live.mjs`: boot. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .opencode/skills/impeccable/scripts/live.mjs --target <path>` instead; then run the rest of this live session from the returned `projectRoot`.
|
||||
2. Open the app URL that serves `pageFile` (infer from `package.json`, docs, terminal output, or an open tab). Never use `serverPort`; it's the helper, not the app. **Cursor:** `browser_navigate` to that URL before polling; do not skip. **Other harnesses:** use the available browser tool; if the URL is uncertain, ask the user once.
|
||||
3. Poll loop with the default long timeout (600000 ms). After every event or `--reply`, run `live-poll.mjs` again immediately. Never pass a short `--timeout=`.
|
||||
|
||||
|
||||
@@ -5,11 +5,12 @@
|
||||
* init flow.
|
||||
*
|
||||
* Path resolution (first match wins):
|
||||
* 1. cwd, if PRODUCT.md or DESIGN.md is there
|
||||
* 2. .agents/context/ then docs/
|
||||
* 3. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user
|
||||
* 1. Active project root, if PRODUCT.md or DESIGN.md is there
|
||||
* 2. Active project .agents/context/ then docs/
|
||||
* 3. Monorepo root context, using the same order, as a per-file fallback
|
||||
* 4. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user
|
||||
* escape hatch, only consulted when defaults are empty
|
||||
* 4. cwd as a "nothing found" default
|
||||
* 5. Active project root as a "nothing found" default
|
||||
*
|
||||
* `resolveContextDir()` and `loadContext()` are also exported for the
|
||||
* server-side scripts (live.mjs, live-server.mjs) that need the structured
|
||||
@@ -19,10 +20,25 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseTargetOptions } from './lib/target-args.mjs';
|
||||
|
||||
const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md'];
|
||||
const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md'];
|
||||
const FALLBACK_DIRS = ['.agents/context', 'docs'];
|
||||
const MONOREPO_MARKER_FILES = ['pnpm-workspace.yaml', 'turbo.json', 'nx.json', 'lerna.json'];
|
||||
const MONOREPO_FALLBACK_PROJECT_DIRS = ['apps', 'packages'];
|
||||
const WORKSPACE_DISCOVERY_IGNORED_DIRS = new Set([
|
||||
'node_modules',
|
||||
'.git',
|
||||
'dist',
|
||||
'build',
|
||||
'.next',
|
||||
'.nuxt',
|
||||
'.svelte-kit',
|
||||
'.turbo',
|
||||
'.cache',
|
||||
'coverage',
|
||||
]);
|
||||
|
||||
// ─── Update check ──────────────────────────────────────────────────────────
|
||||
// Piggyback a lightweight skill-version check on the once-per-session boot.
|
||||
@@ -38,41 +54,600 @@ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // throttle the network poll to o
|
||||
const RENOTIFY_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000; // don't re-surface the same version for a week
|
||||
const FETCH_TIMEOUT_MS = 1200;
|
||||
|
||||
export function resolveContextDir(cwd = process.cwd()) {
|
||||
if (firstExisting(cwd, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return cwd;
|
||||
}
|
||||
for (const rel of FALLBACK_DIRS) {
|
||||
const candidate = path.resolve(cwd, rel);
|
||||
if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
const envDir = process.env.IMPECCABLE_CONTEXT_DIR;
|
||||
if (envDir && envDir.trim()) {
|
||||
const trimmed = envDir.trim();
|
||||
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
||||
}
|
||||
return cwd;
|
||||
export function resolveContextDir(cwd = process.cwd(), options = {}) {
|
||||
return resolveContext(cwd, options).contextDir;
|
||||
}
|
||||
|
||||
export function loadContext(cwd = process.cwd()) {
|
||||
const contextDir = resolveContextDir(cwd);
|
||||
const productPath = firstExisting(contextDir, PRODUCT_NAMES);
|
||||
const designPath = firstExisting(contextDir, DESIGN_NAMES);
|
||||
export function loadContext(cwd = process.cwd(), options = {}) {
|
||||
const resolved = resolveContext(cwd, options);
|
||||
const absCwd = path.resolve(cwd);
|
||||
const productPath = resolved.productPath;
|
||||
const designPath = resolved.designPath;
|
||||
const product = productPath ? safeRead(productPath) : null;
|
||||
const design = designPath ? safeRead(designPath) : null;
|
||||
return {
|
||||
hasProduct: !!product,
|
||||
product,
|
||||
productPath: productPath ? path.relative(cwd, productPath) : null,
|
||||
productPath: productPath ? path.relative(absCwd, productPath) : null,
|
||||
hasDesign: !!design,
|
||||
design,
|
||||
designPath: designPath ? path.relative(cwd, designPath) : null,
|
||||
contextDir,
|
||||
designPath: designPath ? path.relative(absCwd, designPath) : null,
|
||||
contextDir: resolved.contextDir,
|
||||
productContextDir: productPath ? path.dirname(productPath) : null,
|
||||
designContextDir: designPath ? path.dirname(designPath) : null,
|
||||
projectRoot: resolved.projectRoot,
|
||||
repoRoot: resolved.repoRoot,
|
||||
isMonorepo: resolved.isMonorepo,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveContext(cwd = process.cwd(), options = {}) {
|
||||
const absCwd = path.resolve(cwd);
|
||||
const project = resolveProject(absCwd, options);
|
||||
const projectContextDir = resolveLocalContextDir(project.projectRoot);
|
||||
const rootContextDir = project.isMonorepo && project.repoRoot !== project.projectRoot
|
||||
? resolveLocalContextDir(project.repoRoot)
|
||||
: null;
|
||||
|
||||
let productPath =
|
||||
(projectContextDir ? firstExisting(projectContextDir, PRODUCT_NAMES) : null)
|
||||
|| (rootContextDir ? firstExisting(rootContextDir, PRODUCT_NAMES) : null);
|
||||
let designPath =
|
||||
(projectContextDir ? firstExisting(projectContextDir, DESIGN_NAMES) : null)
|
||||
|| (rootContextDir ? firstExisting(rootContextDir, DESIGN_NAMES) : null);
|
||||
|
||||
let envContextDir = null;
|
||||
if (!productPath && !designPath) {
|
||||
envContextDir = resolveEnvContextDir(absCwd);
|
||||
if (envContextDir) {
|
||||
productPath = firstExisting(envContextDir, PRODUCT_NAMES);
|
||||
designPath = firstExisting(envContextDir, DESIGN_NAMES);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
contextDir: productPath
|
||||
? path.dirname(productPath)
|
||||
: designPath
|
||||
? path.dirname(designPath)
|
||||
: envContextDir || project.projectRoot,
|
||||
productPath,
|
||||
designPath,
|
||||
projectRoot: project.projectRoot,
|
||||
repoRoot: project.repoRoot,
|
||||
isMonorepo: project.isMonorepo,
|
||||
targetDir: project.targetDir,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveProjectRoot(cwd = process.cwd(), options = {}) {
|
||||
return resolveProject(cwd, options).projectRoot;
|
||||
}
|
||||
|
||||
export function resolveTargetSelection(cwd = process.cwd(), options = {}) {
|
||||
if (hasTargetOption(options)) return null;
|
||||
const project = resolveProject(cwd);
|
||||
if (
|
||||
!project.isMonorepo
|
||||
|| !project.projectRoot
|
||||
|| !project.repoRoot
|
||||
|| path.resolve(project.projectRoot) !== path.resolve(project.repoRoot)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
targetPath: null,
|
||||
projectRoot: project.projectRoot,
|
||||
repoRoot: project.repoRoot,
|
||||
targetCandidates: discoverTargetCandidates(project.repoRoot),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveProject(cwd = process.cwd(), options = {}) {
|
||||
const absCwd = path.resolve(cwd);
|
||||
const targetDir = resolveTargetDir(absCwd, options);
|
||||
let repoRoot = findMonorepoRoot(targetDir);
|
||||
if (!repoRoot && targetDir !== absCwd) {
|
||||
const cwdRepoRoot = findMonorepoRoot(absCwd);
|
||||
if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {
|
||||
repoRoot = cwdRepoRoot;
|
||||
}
|
||||
}
|
||||
if (!repoRoot) {
|
||||
return {
|
||||
targetDir,
|
||||
projectRoot: absCwd,
|
||||
repoRoot: absCwd,
|
||||
isMonorepo: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
targetDir,
|
||||
projectRoot: resolveWorkspaceProjectRoot(repoRoot, targetDir) || repoRoot,
|
||||
repoRoot,
|
||||
isMonorepo: true,
|
||||
};
|
||||
}
|
||||
|
||||
function isPathInside(candidate, root) {
|
||||
const rel = path.relative(root, candidate);
|
||||
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
|
||||
}
|
||||
|
||||
function resolveLocalContextDir(root) {
|
||||
if (firstExisting(root, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return root;
|
||||
}
|
||||
for (const rel of FALLBACK_DIRS) {
|
||||
const candidate = path.resolve(root, rel);
|
||||
if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveEnvContextDir(cwd) {
|
||||
const envDir = process.env.IMPECCABLE_CONTEXT_DIR;
|
||||
if (!envDir || !envDir.trim()) return null;
|
||||
const trimmed = envDir.trim();
|
||||
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
||||
}
|
||||
|
||||
function resolveTargetDir(cwd, options = {}) {
|
||||
const targetPath = options && typeof options === 'object' ? options.targetPath : null;
|
||||
if (!targetPath || !String(targetPath).trim()) return cwd;
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
try {
|
||||
const stat = fs.statSync(abs);
|
||||
return stat.isDirectory() ? abs : path.dirname(abs);
|
||||
} catch {
|
||||
return path.extname(abs) ? path.dirname(abs) : abs;
|
||||
}
|
||||
}
|
||||
|
||||
function findMonorepoRoot(startDir) {
|
||||
let dir = path.resolve(startDir);
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
while (true) {
|
||||
if (dir === homeDir) return null;
|
||||
if (isMonorepoRoot(dir)) return dir;
|
||||
if (hasGitBoundary(dir)) return null;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function isMonorepoRoot(dir) {
|
||||
if (readWorkspacePatterns(dir).some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) return true;
|
||||
if (!MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(dir, file)))) return false;
|
||||
return hasFallbackWorkspaceChildren(dir);
|
||||
}
|
||||
|
||||
function hasGitBoundary(dir) {
|
||||
return fs.existsSync(path.join(dir, '.git'));
|
||||
}
|
||||
|
||||
function hasFallbackWorkspaceChildren(dir) {
|
||||
for (const name of MONOREPO_FALLBACK_PROJECT_DIRS) {
|
||||
const base = path.join(dir, name);
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(base, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (entries.some((entry) => entry.isDirectory() && !isIgnoredWorkspaceDiscoveryDir(entry.name))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function discoverTargetCandidates(repoRoot) {
|
||||
const roots = new Map();
|
||||
for (const pattern of readWorkspacePatterns(repoRoot)) {
|
||||
for (const root of discoverRootsForPattern(repoRoot, pattern)) {
|
||||
roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root);
|
||||
}
|
||||
}
|
||||
if (MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(repoRoot, file)))) {
|
||||
for (const name of MONOREPO_FALLBACK_PROJECT_DIRS) {
|
||||
const base = path.join(repoRoot, name);
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(base, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
||||
const root = path.join(base, entry.name);
|
||||
roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...roots.entries()]
|
||||
.filter(([rel]) => rel && !rel.startsWith('..'))
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([rel, root]) => {
|
||||
const targetExample = findTargetExample(repoRoot, root);
|
||||
return {
|
||||
name: path.basename(root),
|
||||
path: rel,
|
||||
targetExample,
|
||||
...resolveCandidateContextSummary(repoRoot, root, targetExample),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function resolveCandidateContextSummary(repoRoot, projectRoot, targetPath) {
|
||||
const ctx = resolveContext(repoRoot, { targetPath });
|
||||
return {
|
||||
productStatus: contextSourceStatus(ctx.productPath, repoRoot, projectRoot),
|
||||
productPath: contextSourcePath(ctx.productPath, repoRoot),
|
||||
designStatus: contextSourceStatus(ctx.designPath, repoRoot, projectRoot),
|
||||
designPath: contextSourcePath(ctx.designPath, repoRoot),
|
||||
};
|
||||
}
|
||||
|
||||
function contextSourceStatus(filePath, repoRoot, projectRoot) {
|
||||
if (!filePath) return 'missing';
|
||||
const absPath = path.resolve(filePath);
|
||||
const absProjectRoot = path.resolve(projectRoot);
|
||||
const absRepoRoot = path.resolve(repoRoot);
|
||||
if (isPathInsideOrEqual(absPath, absProjectRoot)) {
|
||||
return path.dirname(absPath) === absProjectRoot ? 'child' : 'fallback';
|
||||
}
|
||||
if (absProjectRoot !== absRepoRoot && isPathInsideOrEqual(absPath, absRepoRoot)) {
|
||||
return 'inherited';
|
||||
}
|
||||
return 'fallback';
|
||||
}
|
||||
|
||||
function contextSourcePath(filePath, repoRoot) {
|
||||
if (!filePath) return null;
|
||||
const rel = path.relative(repoRoot, filePath);
|
||||
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {
|
||||
return rel.split(path.sep).join('/');
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function discoverRootsForPattern(repoRoot, rawPattern) {
|
||||
const pattern = normalizeWorkspacePattern(rawPattern);
|
||||
if (!pattern || pattern.startsWith('!')) return [];
|
||||
const segments = pattern.split('/').filter(Boolean);
|
||||
if (!segments.length) return [];
|
||||
const firstGlobIndex = segments.findIndex((segment) => segment.includes('*'));
|
||||
const literalPrefix = firstGlobIndex === -1 ? segments : segments.slice(0, firstGlobIndex);
|
||||
const base = path.join(repoRoot, ...literalPrefix);
|
||||
if (!fs.existsSync(base)) return [];
|
||||
if (segments.includes('**')) {
|
||||
const packageRoots = [];
|
||||
walkDirs(base, (dir) => {
|
||||
if (dir !== base && isCandidateProjectRoot(dir)) packageRoots.push(dir);
|
||||
});
|
||||
if (packageRoots.length) return packageRoots;
|
||||
return directChildDirs(base);
|
||||
}
|
||||
return expandSimplePattern(repoRoot, segments);
|
||||
}
|
||||
|
||||
function expandSimplePattern(repoRoot, patternSegments, index = 0, current = repoRoot) {
|
||||
if (index >= patternSegments.length) return fs.existsSync(current) ? [current] : [];
|
||||
const segment = patternSegments[index];
|
||||
if (!segment.includes('*')) {
|
||||
return expandSimplePattern(repoRoot, patternSegments, index + 1, path.join(current, segment));
|
||||
}
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(current, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const roots = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
||||
if (!segmentMatches(segment, entry.name)) continue;
|
||||
roots.push(...expandSimplePattern(repoRoot, patternSegments, index + 1, path.join(current, entry.name)));
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
function directChildDirs(dir) {
|
||||
try {
|
||||
return fs.readdirSync(dir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() && !isIgnoredWorkspaceDiscoveryDir(entry.name))
|
||||
.map((entry) => path.join(dir, entry.name));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function walkDirs(root, visit) {
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(root, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
||||
const dir = path.join(root, entry.name);
|
||||
visit(dir);
|
||||
walkDirs(dir, visit);
|
||||
}
|
||||
}
|
||||
|
||||
function isCandidateProjectRoot(dir) {
|
||||
return !!(
|
||||
fs.existsSync(path.join(dir, 'package.json'))
|
||||
|| firstExisting(dir, [...PRODUCT_NAMES, ...DESIGN_NAMES])
|
||||
|| fs.existsSync(path.join(dir, 'src'))
|
||||
|| fs.existsSync(path.join(dir, 'app'))
|
||||
|| fs.existsSync(path.join(dir, 'pages'))
|
||||
|| fs.existsSync(path.join(dir, 'public'))
|
||||
);
|
||||
}
|
||||
|
||||
function isIgnoredWorkspaceDiscoveryDir(name) {
|
||||
return name.startsWith('.') || WORKSPACE_DISCOVERY_IGNORED_DIRS.has(name);
|
||||
}
|
||||
|
||||
function findTargetExample(repoRoot, projectRoot) {
|
||||
const examples = [
|
||||
'src/App.jsx',
|
||||
'src/App.tsx',
|
||||
'src/main.jsx',
|
||||
'src/main.tsx',
|
||||
'src/index.jsx',
|
||||
'src/index.ts',
|
||||
'app/page.tsx',
|
||||
'pages/index.tsx',
|
||||
'public/index.html',
|
||||
];
|
||||
for (const rel of examples) {
|
||||
const abs = path.join(projectRoot, rel);
|
||||
if (fs.existsSync(abs)) return path.relative(repoRoot, abs).split(path.sep).join('/');
|
||||
}
|
||||
return path.relative(repoRoot, projectRoot).split(path.sep).join('/');
|
||||
}
|
||||
|
||||
function resolveWorkspaceProjectRoot(repoRoot, targetDir) {
|
||||
const rel = path.relative(repoRoot, targetDir);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return repoRoot;
|
||||
const relSegments = rel.split(path.sep).filter(Boolean);
|
||||
const patterns = readWorkspacePatterns(repoRoot);
|
||||
const excluded = isExcludedByWorkspacePattern(relSegments, patterns);
|
||||
if (!excluded) {
|
||||
for (const pattern of patterns) {
|
||||
const projectRoot = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern);
|
||||
if (projectRoot) return projectRoot;
|
||||
}
|
||||
}
|
||||
if (excluded) return repoRoot;
|
||||
if (
|
||||
relSegments.length >= 2
|
||||
&& MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0])
|
||||
) {
|
||||
return path.join(repoRoot, relSegments[0], relSegments[1]);
|
||||
}
|
||||
const nearest = nearestProjectLikeRoot(repoRoot, targetDir);
|
||||
if (nearest) return nearest;
|
||||
return repoRoot;
|
||||
}
|
||||
|
||||
function isExcludedByWorkspacePattern(relSegments, patterns) {
|
||||
return patterns.some((rawPattern) => {
|
||||
const pattern = normalizeWorkspacePattern(rawPattern);
|
||||
if (!pattern.startsWith('!')) return false;
|
||||
return workspacePatternMatchesRel(pattern.slice(1), relSegments);
|
||||
});
|
||||
}
|
||||
|
||||
function nearestProjectLikeRoot(repoRoot, targetDir) {
|
||||
let dir = path.resolve(targetDir);
|
||||
const stop = path.resolve(repoRoot);
|
||||
while (dir && dir !== stop) {
|
||||
if (
|
||||
firstExisting(dir, [...PRODUCT_NAMES, ...DESIGN_NAMES])
|
||||
|| fs.existsSync(path.join(dir, 'package.json'))
|
||||
) {
|
||||
return dir;
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function nearestPackageRootBetween(repoRoot, targetDir, stopDir) {
|
||||
let dir = path.resolve(targetDir);
|
||||
const stop = path.resolve(stopDir || repoRoot);
|
||||
const root = path.resolve(repoRoot);
|
||||
while (dir && dir !== stop && isPathInsideOrEqual(dir, root)) {
|
||||
if (fs.existsSync(path.join(dir, 'package.json'))) return dir;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPathInsideOrEqual(candidate, root) {
|
||||
return path.resolve(candidate) === path.resolve(root) || isPathInside(candidate, root);
|
||||
}
|
||||
|
||||
function workspacePatternMatchesRel(pattern, relSegments) {
|
||||
const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return false;
|
||||
if (patternSegments.includes('**')) {
|
||||
const firstGlobIndex = patternSegments.findIndex((segment) => segment.includes('*'));
|
||||
const literalPrefix = firstGlobIndex === -1
|
||||
? patternSegments
|
||||
: patternSegments.slice(0, firstGlobIndex);
|
||||
if (relSegments.length < literalPrefix.length + 1) return false;
|
||||
for (let i = 0; i < literalPrefix.length; i++) {
|
||||
if (!segmentMatches(literalPrefix[i], relSegments[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (relSegments.length < patternSegments.length) return false;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function readWorkspacePatterns(repoRoot) {
|
||||
return [
|
||||
...readPackageWorkspaces(repoRoot),
|
||||
...readPnpmWorkspaces(repoRoot),
|
||||
...readLernaWorkspaces(repoRoot),
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
function readPackageWorkspaces(repoRoot) {
|
||||
const pkg = readJson(path.join(repoRoot, 'package.json'));
|
||||
const workspaces = pkg?.workspaces;
|
||||
if (Array.isArray(workspaces)) return workspaces;
|
||||
if (Array.isArray(workspaces?.packages)) return workspaces.packages;
|
||||
return [];
|
||||
}
|
||||
|
||||
function readLernaWorkspaces(repoRoot) {
|
||||
const lerna = readJson(path.join(repoRoot, 'lerna.json'));
|
||||
return Array.isArray(lerna?.packages) ? lerna.packages : [];
|
||||
}
|
||||
|
||||
function readPnpmWorkspaces(repoRoot) {
|
||||
try {
|
||||
const body = fs.readFileSync(path.join(repoRoot, 'pnpm-workspace.yaml'), 'utf-8');
|
||||
const patterns = [];
|
||||
let inPackages = false;
|
||||
for (const line of body.split(/\r?\n/)) {
|
||||
const trimmed = stripYamlInlineComment(line).trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const flowMatch = trimmed.match(/^packages:\s*\[(.*)\]\s*$/);
|
||||
if (flowMatch) {
|
||||
patterns.push(...parseYamlFlowList(flowMatch[1]));
|
||||
inPackages = false;
|
||||
continue;
|
||||
}
|
||||
if (/^packages:\s*$/.test(trimmed)) {
|
||||
inPackages = true;
|
||||
continue;
|
||||
}
|
||||
if (inPackages && /^[A-Za-z0-9_-]+:\s*/.test(trimmed)) break;
|
||||
if (inPackages) {
|
||||
const match = trimmed.match(/^-\s*(.+)$/);
|
||||
if (match) patterns.push(unquoteYamlValue(match[1]));
|
||||
}
|
||||
}
|
||||
return patterns;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function stripYamlInlineComment(line) {
|
||||
let quote = null;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
if ((ch === '"' || ch === "'") && line[i - 1] !== '\\') {
|
||||
quote = quote === ch ? null : quote || ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === '#' && !quote) return line.slice(0, i);
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
function parseYamlFlowList(body) {
|
||||
const items = [];
|
||||
let quote = null;
|
||||
let current = '';
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
const ch = body[i];
|
||||
if ((ch === '"' || ch === "'") && body[i - 1] !== '\\') {
|
||||
quote = quote === ch ? null : quote || ch;
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === ',' && !quote) {
|
||||
const value = unquoteYamlValue(current);
|
||||
if (value) items.push(value);
|
||||
current = '';
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
}
|
||||
const value = unquoteYamlValue(current);
|
||||
if (value) items.push(value);
|
||||
return items;
|
||||
}
|
||||
|
||||
function unquoteYamlValue(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '');
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function projectRootFromWorkspacePattern(repoRoot, relSegments, rawPattern) {
|
||||
const pattern = normalizeWorkspacePattern(rawPattern);
|
||||
if (!pattern || pattern.startsWith('!')) return null;
|
||||
const patternSegments = pattern.split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return null;
|
||||
if (patternSegments.includes('**')) {
|
||||
return projectRootFromDoubleStarPattern(repoRoot, relSegments, patternSegments);
|
||||
}
|
||||
if (relSegments.length < patternSegments.length) return null;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return null;
|
||||
}
|
||||
return path.join(repoRoot, ...relSegments.slice(0, patternSegments.length));
|
||||
}
|
||||
|
||||
function projectRootFromDoubleStarPattern(repoRoot, relSegments, patternSegments) {
|
||||
const firstGlobIndex = patternSegments.findIndex((segment) => segment.includes('*'));
|
||||
const literalPrefix = firstGlobIndex === -1
|
||||
? patternSegments
|
||||
: patternSegments.slice(0, firstGlobIndex);
|
||||
if (relSegments.length < literalPrefix.length + 1) return null;
|
||||
for (let i = 0; i < literalPrefix.length; i++) {
|
||||
if (!segmentMatches(literalPrefix[i], relSegments[i])) return null;
|
||||
}
|
||||
const prefixDir = path.join(repoRoot, ...literalPrefix);
|
||||
const targetDir = path.join(repoRoot, ...relSegments);
|
||||
const packageRoot = nearestPackageRootBetween(repoRoot, targetDir, prefixDir);
|
||||
if (packageRoot) return packageRoot;
|
||||
return path.join(repoRoot, ...relSegments.slice(0, literalPrefix.length + 1));
|
||||
}
|
||||
|
||||
function normalizeWorkspacePattern(pattern) {
|
||||
return String(pattern || '')
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '')
|
||||
.replace(/^\.\//, '')
|
||||
.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function segmentMatches(patternSegment, relSegment) {
|
||||
if (patternSegment === '*') return true;
|
||||
if (!patternSegment.includes('*')) return patternSegment === relSegment;
|
||||
const re = new RegExp(`^${escapeRegExp(patternSegment).replace(/\\\*/g, '[^/]*')}$`);
|
||||
return re.test(relSegment);
|
||||
}
|
||||
|
||||
function firstExisting(dir, names) {
|
||||
for (const name of names) {
|
||||
const abs = path.join(dir, name);
|
||||
@@ -89,6 +664,10 @@ function safeRead(p) {
|
||||
}
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the register (`brand` or `product`) out of PRODUCT.md by looking
|
||||
* for a `## Register` section and reading the first non-empty line that
|
||||
@@ -233,7 +812,24 @@ async function computeUpdateDirective(now = Date.now()) {
|
||||
}
|
||||
|
||||
async function cli() {
|
||||
const ctx = loadContext(process.cwd());
|
||||
let cliOptions;
|
||||
try {
|
||||
cliOptions = parseCliOptions(process.argv.slice(2));
|
||||
} catch (err) {
|
||||
if (err?.name === 'TargetArgError') {
|
||||
process.stderr.write(`${err.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const targetProvided = hasTargetOption(cliOptions);
|
||||
const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null;
|
||||
const selection = resolveTargetSelection(process.cwd(), cliOptions);
|
||||
if (selection) {
|
||||
process.stdout.write(buildTargetSelectionDirective(selection) + '\n');
|
||||
process.exit(0);
|
||||
}
|
||||
const ctx = loadContext(process.cwd(), cliOptions);
|
||||
const updateDirective = await computeUpdateDirective();
|
||||
|
||||
if (!ctx.hasProduct) {
|
||||
@@ -244,6 +840,10 @@ async function cli() {
|
||||
'Stop the current task, load reference/init.md, and follow its ' +
|
||||
'instructions to write PRODUCT.md before resuming.',
|
||||
];
|
||||
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
|
||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||
parts.push(buildMissingTargetDirective());
|
||||
}
|
||||
if (updateDirective) parts.push(updateDirective);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
process.exit(0);
|
||||
@@ -252,6 +852,10 @@ async function cli() {
|
||||
if (ctx.hasDesign) {
|
||||
parts.push(`# DESIGN.md\n\n${ctx.design.trim()}`);
|
||||
}
|
||||
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
|
||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||
parts.push(buildMissingTargetDirective());
|
||||
}
|
||||
const register = extractRegister(ctx.product);
|
||||
const next = register
|
||||
? `NEXT STEP: This project's register is \`${register}\`. You MUST now read \`reference/${register}.md\` before producing any design output.`
|
||||
@@ -261,6 +865,60 @@ async function cli() {
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
}
|
||||
|
||||
function parseCliOptions(args) {
|
||||
return parseTargetOptions(args, { strict: true });
|
||||
}
|
||||
|
||||
function hasTargetOption(options) {
|
||||
return !!(options && typeof options.targetPath === 'string' && options.targetPath.trim());
|
||||
}
|
||||
|
||||
function pathExistsForTarget(cwd, targetPath) {
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
return fs.existsSync(abs);
|
||||
}
|
||||
|
||||
function buildResolvedContextDirective(ctx, options, { targetExists = null } = {}) {
|
||||
const targetPath = hasTargetOption(options) ? options.targetPath : null;
|
||||
return `RESOLVED_CONTEXT:\n${JSON.stringify({
|
||||
targetPath,
|
||||
...(targetPath ? { targetExists } : {}),
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
productPath: ctx.productPath,
|
||||
designPath: ctx.designPath,
|
||||
}, null, 2)}`;
|
||||
}
|
||||
|
||||
function shouldWarnMissingTarget(ctx, targetProvided, targetExists = null) {
|
||||
if (ctx.isMonorepo && targetProvided && targetExists === false) return true;
|
||||
return !!(
|
||||
ctx.isMonorepo
|
||||
&& (!targetProvided || targetExists === false)
|
||||
&& ctx.projectRoot
|
||||
&& ctx.repoRoot
|
||||
&& path.resolve(ctx.projectRoot) === path.resolve(ctx.repoRoot)
|
||||
);
|
||||
}
|
||||
|
||||
function buildMissingTargetDirective() {
|
||||
const script = process.argv[1] || 'context.mjs';
|
||||
return (
|
||||
'MONOREPO_TARGET_REQUIRED: This is a monorepo and context.mjs ran without --target. ' +
|
||||
'If the user named a file, route, or child app, do not answer from this output. ' +
|
||||
`Rerun \`node ${script} --target <path>\` and answer from that run's RESOLVED_CONTEXT fields.`
|
||||
);
|
||||
}
|
||||
|
||||
function buildTargetSelectionDirective(selection) {
|
||||
return (
|
||||
`TARGET_SELECTION_REQUIRED:\n${JSON.stringify(selection, null, 2)}\n\n` +
|
||||
'Show each app with its productStatus/productPath and designStatus/designPath so the user can see child overrides, inherited root files, fallback files, or missing files before choosing. ' +
|
||||
'Ask the user which app Impeccable should use, then rerun Impeccable helper commands from that child app cwd using this same scripts directory. ' +
|
||||
'Use `--target <path>` only as a fallback when changing cwd is not possible, or when the user explicitly named a file/path.'
|
||||
);
|
||||
}
|
||||
|
||||
// Run cli() only when this module is the entry point. Compare realpaths
|
||||
// rather than endsWith(): a loose suffix match also fires for unrelated
|
||||
// scripts like `load-context.mjs`, and realpath tolerates symlinked
|
||||
|
||||
@@ -1,50 +1,52 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { resolveProjectRoot } from '../context.mjs';
|
||||
|
||||
export const IMPECCABLE_DIR = '.impeccable';
|
||||
export const LIVE_DIR = 'live';
|
||||
export const CRITIQUE_DIR = 'critique';
|
||||
|
||||
export function getImpeccableDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, IMPECCABLE_DIR);
|
||||
export function getImpeccableDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), IMPECCABLE_DIR);
|
||||
}
|
||||
|
||||
export function getDesignSidecarPath(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), 'design.json');
|
||||
export function getDesignSidecarPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), 'design.json');
|
||||
}
|
||||
|
||||
export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd) {
|
||||
export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd, options = {}) {
|
||||
const projectRoot = resolveProjectRoot(cwd, options);
|
||||
const candidates = [
|
||||
getDesignSidecarPath(cwd),
|
||||
path.join(cwd, 'DESIGN.json'),
|
||||
getDesignSidecarPath(cwd, options),
|
||||
path.join(projectRoot, 'DESIGN.json'),
|
||||
];
|
||||
const contextLegacy = path.join(contextDir, 'DESIGN.json');
|
||||
if (!candidates.includes(contextLegacy)) candidates.push(contextLegacy);
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) {
|
||||
return firstExisting(getDesignSidecarCandidates(cwd, contextDir));
|
||||
export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd, options = {}) {
|
||||
return firstExisting(getDesignSidecarCandidates(cwd, contextDir, options));
|
||||
}
|
||||
|
||||
export function getLiveDir(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), LIVE_DIR);
|
||||
export function getLiveDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), LIVE_DIR);
|
||||
}
|
||||
|
||||
export function getLiveConfigPath(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'config.json');
|
||||
export function getLiveConfigPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'config.json');
|
||||
}
|
||||
|
||||
export function getLegacyLiveConfigPath(scriptsDir) {
|
||||
return path.join(scriptsDir, 'config.json');
|
||||
}
|
||||
|
||||
export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env } = {}) {
|
||||
export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env, targetPath } = {}) {
|
||||
if (env.IMPECCABLE_LIVE_CONFIG && env.IMPECCABLE_LIVE_CONFIG.trim()) {
|
||||
const configured = env.IMPECCABLE_LIVE_CONFIG.trim();
|
||||
return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured);
|
||||
}
|
||||
const primary = getLiveConfigPath(cwd);
|
||||
const primary = getLiveConfigPath(cwd, { targetPath });
|
||||
if (fs.existsSync(primary)) return primary;
|
||||
if (scriptsDir) {
|
||||
const legacy = getLegacyLiveConfigPath(scriptsDir);
|
||||
@@ -53,16 +55,16 @@ export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = p
|
||||
return primary;
|
||||
}
|
||||
|
||||
export function getLiveServerPath(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'server.json');
|
||||
export function getLiveServerPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'server.json');
|
||||
}
|
||||
|
||||
export function getLegacyLiveServerPath(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live.json');
|
||||
export function getLegacyLiveServerPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live.json');
|
||||
}
|
||||
|
||||
export function readLiveServerInfo(cwd = process.cwd()) {
|
||||
for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) {
|
||||
export function readLiveServerInfo(cwd = process.cwd(), options = {}) {
|
||||
for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) {
|
||||
try {
|
||||
const info = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
if (info && typeof info.pid === 'number' && !isLiveServerPidReachable(info.pid)) {
|
||||
@@ -88,37 +90,37 @@ export function isLiveServerPidReachable(pid) {
|
||||
}
|
||||
}
|
||||
|
||||
export function writeLiveServerInfo(cwd = process.cwd(), info) {
|
||||
const filePath = getLiveServerPath(cwd);
|
||||
export function writeLiveServerInfo(cwd = process.cwd(), info, options = {}) {
|
||||
const filePath = getLiveServerPath(cwd, options);
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(info));
|
||||
return filePath;
|
||||
}
|
||||
|
||||
export function removeLiveServerInfo(cwd = process.cwd()) {
|
||||
for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) {
|
||||
export function removeLiveServerInfo(cwd = process.cwd(), options = {}) {
|
||||
for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) {
|
||||
try { fs.unlinkSync(filePath); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
export function getLiveSessionsDir(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'sessions');
|
||||
export function getLiveSessionsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'sessions');
|
||||
}
|
||||
|
||||
export function getLegacyLiveSessionsDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live', 'sessions');
|
||||
export function getLegacyLiveSessionsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'sessions');
|
||||
}
|
||||
|
||||
export function getLiveAnnotationsDir(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'annotations');
|
||||
export function getLiveAnnotationsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'annotations');
|
||||
}
|
||||
|
||||
export function getCritiqueDir(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), CRITIQUE_DIR);
|
||||
export function getCritiqueDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), CRITIQUE_DIR);
|
||||
}
|
||||
|
||||
export function getLegacyLiveAnnotationsDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live', 'annotations');
|
||||
export function getLegacyLiveAnnotationsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'annotations');
|
||||
}
|
||||
|
||||
function firstExisting(paths) {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
class TargetArgError extends Error {
|
||||
constructor(message, code) {
|
||||
super(message);
|
||||
this.name = 'TargetArgError';
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseTargetPath(args = [], { strict = false } = {}) {
|
||||
let targetPath = null;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = String(args[i]);
|
||||
if (arg === '--target' || arg === '-t') {
|
||||
const next = args[i + 1];
|
||||
if (next && !String(next).startsWith('-')) {
|
||||
targetPath = String(next);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (strict) {
|
||||
throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith('--target=')) {
|
||||
const value = arg.slice('--target='.length);
|
||||
if (value) {
|
||||
targetPath = value;
|
||||
continue;
|
||||
}
|
||||
if (strict) {
|
||||
throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING');
|
||||
}
|
||||
}
|
||||
}
|
||||
return targetPath;
|
||||
}
|
||||
|
||||
export function parseTargetOptions(args = [], options = {}) {
|
||||
const targetPath = parseTargetPath(args, options);
|
||||
return targetPath ? { targetPath } : {};
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import path from 'node:path';
|
||||
import net from 'node:net';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseDesignMd } from './lib/design-parser.mjs';
|
||||
import { resolveContextDir } from './context.mjs';
|
||||
import { loadContext } from './context.mjs';
|
||||
import {
|
||||
assembleLiveBrowserScript,
|
||||
assertLiveBrowserScriptParts,
|
||||
@@ -55,7 +55,11 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
// PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
|
||||
// DESIGN sidecar is project-local at .impeccable/design.json, with legacy
|
||||
// DESIGN.json fallback for existing projects.
|
||||
const CONTEXT_DIR = resolveContextDir(process.cwd());
|
||||
const PROJECT_CONTEXT = loadContext(process.cwd());
|
||||
const CONTEXT_DIR = PROJECT_CONTEXT.contextDir;
|
||||
const DESIGN_MD_PATH = PROJECT_CONTEXT.designPath
|
||||
? path.resolve(process.cwd(), PROJECT_CONTEXT.designPath)
|
||||
: null;
|
||||
const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway
|
||||
const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s
|
||||
|
||||
@@ -371,10 +375,7 @@ function hasProjectContext() {
|
||||
// PRODUCT.md carries brand voice / anti-references — that's what determines
|
||||
// whether variants are brand-aware. DESIGN.md (visual tokens) is a separate
|
||||
// concern, surfaced by the design panel's own empty state.
|
||||
try {
|
||||
fs.accessSync(path.join(CONTEXT_DIR, 'PRODUCT.md'), fs.constants.R_OK);
|
||||
return true;
|
||||
} catch { return false; }
|
||||
return !!PROJECT_CONTEXT.hasProduct;
|
||||
}
|
||||
|
||||
function statOrNull(filePath) {
|
||||
@@ -549,8 +550,8 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
const token = url.searchParams.get('token');
|
||||
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
|
||||
|
||||
const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md');
|
||||
const jsonPath = resolveDesignSidecarPath(process.cwd(), CONTEXT_DIR) || getDesignSidecarPath(process.cwd());
|
||||
const mdPath = DESIGN_MD_PATH;
|
||||
const jsonPath = resolveDesignSidecarPath(process.cwd(), PROJECT_CONTEXT.designContextDir || CONTEXT_DIR) || getDesignSidecarPath(process.cwd());
|
||||
const mdStat = statOrNull(mdPath);
|
||||
const jsonStat = statOrNull(jsonPath);
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import path from 'node:path';
|
||||
import { resolveProjectRoot } from './context.mjs';
|
||||
import { parseTargetPath } from './lib/target-args.mjs';
|
||||
|
||||
export function resolveLiveTarget(cwd = process.cwd(), args = []) {
|
||||
const originalCwd = path.resolve(cwd);
|
||||
let targetPath = null;
|
||||
try {
|
||||
targetPath = parseTargetPath(args, { strict: true });
|
||||
} catch (err) {
|
||||
if (err?.name === 'TargetArgError') {
|
||||
process.stderr.write(`${err.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const absoluteTargetPath = targetPath
|
||||
? path.isAbsolute(targetPath) ? targetPath : path.resolve(originalCwd, targetPath)
|
||||
: null;
|
||||
const projectRoot = targetPath
|
||||
? resolveProjectRoot(originalCwd, { targetPath: absoluteTargetPath })
|
||||
: originalCwd;
|
||||
return {
|
||||
originalCwd,
|
||||
projectRoot,
|
||||
targetPath,
|
||||
absoluteTargetPath,
|
||||
targetOptions: absoluteTargetPath ? { targetPath: absoluteTargetPath } : {},
|
||||
};
|
||||
}
|
||||
@@ -21,14 +21,16 @@ import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadContext } from './context.mjs';
|
||||
import { loadContext, resolveTargetSelection } from './context.mjs';
|
||||
import { resolveFiles } from './live-inject.mjs';
|
||||
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
|
||||
import { resolveLiveTarget } from './live-target.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
async function liveCli() {
|
||||
const args = process.argv.slice(2);
|
||||
const liveTarget = resolveLiveTarget(process.cwd(), args);
|
||||
|
||||
if (args.includes('--help') || args.includes('-h')) {
|
||||
console.log(`Usage: node live.mjs
|
||||
@@ -38,37 +40,78 @@ Prepare everything for live variant mode in a single command:
|
||||
- Starts (or reuses) the live server in the background
|
||||
- Injects the browser script tag
|
||||
- Reads PRODUCT.md / DESIGN.md for project context
|
||||
- In monorepos, choose a child app first; --target <path> is the fallback/manual path
|
||||
|
||||
On success, prints a JSON blob with:
|
||||
{ ok, serverPort, serverToken, pageFile, hasContext, context }
|
||||
{ ok, serverPort, serverToken, pageFiles, projectRoot, repoRoot, targetPath, productPath, designPath }
|
||||
|
||||
On target_selection_required, prints:
|
||||
{ ok: false, error: "target_selection_required", targetCandidates }
|
||||
|
||||
On config_missing, prints:
|
||||
{ ok: false, error: "config_missing", configPath, hint }
|
||||
|
||||
The agent should then:
|
||||
1. If config_missing, create the config and re-run this script
|
||||
2. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort)
|
||||
3. Enter the poll loop: node live-poll.mjs`);
|
||||
1. If target_selection_required, ask which app to use and rerun from that child cwd
|
||||
2. If config_missing, create the config and re-run this script
|
||||
3. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort)
|
||||
4. Enter the poll loop: node live-poll.mjs`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const targetSelection = resolveTargetSelection(liveTarget.originalCwd, liveTarget.targetOptions);
|
||||
if (targetSelection) {
|
||||
console.log(JSON.stringify({
|
||||
ok: false,
|
||||
error: 'target_selection_required',
|
||||
...targetSelection,
|
||||
hint: 'Ask the user which app Impeccable should use, then rerun live from that child app cwd. Use --target <path> only as a fallback or explicit path diagnostic.',
|
||||
}, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const ctx = loadContext(liveTarget.originalCwd, liveTarget.targetOptions);
|
||||
const activeCwd = ctx.projectRoot;
|
||||
const outputTargetPath = liveTarget.targetPath || null;
|
||||
|
||||
const missingContext = missingLiveContext(ctx);
|
||||
if (missingContext.length > 0) {
|
||||
console.log(JSON.stringify({
|
||||
ok: false,
|
||||
error: 'context_missing',
|
||||
missing: missingContext,
|
||||
nextCommand: missingContext.includes('PRODUCT.md') ? 'init' : 'document',
|
||||
targetPath: outputTargetPath,
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
productPath: ctx.productPath,
|
||||
designPath: ctx.designPath,
|
||||
}, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 1. Check config (fail fast if missing — no point starting anything else)
|
||||
const checkOut = runScript('live-inject.mjs', ['--check']);
|
||||
const checkOut = runScript('live-inject.mjs', ['--check'], { cwd: activeCwd });
|
||||
const checkResult = safeParse(checkOut);
|
||||
if (!checkResult || !checkResult.ok) {
|
||||
console.log(JSON.stringify(checkResult || { ok: false, error: 'check_failed', raw: checkOut }));
|
||||
console.log(JSON.stringify({
|
||||
...(checkResult || { ok: false, error: 'check_failed', raw: checkOut }),
|
||||
targetPath: outputTargetPath,
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
}));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 2. Start server (or reuse existing)
|
||||
const serverInfo = ensureServerRunning();
|
||||
const serverInfo = ensureServerRunning(activeCwd);
|
||||
if (!serverInfo) {
|
||||
console.log(JSON.stringify({ ok: false, error: 'server_start_failed' }));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 3. Inject the script tag at the current port
|
||||
const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)]);
|
||||
const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)], { cwd: activeCwd });
|
||||
const injectResult = safeParse(injectOut);
|
||||
if (!injectResult || !injectResult.ok) {
|
||||
console.log(JSON.stringify({
|
||||
@@ -80,22 +123,23 @@ The agent should then:
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 4. Load PRODUCT.md + DESIGN.md context.
|
||||
const ctx = loadContext(process.cwd());
|
||||
|
||||
// 5. Compute drift-heal: compare resolved inject targets against the
|
||||
// 4. 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);
|
||||
const resolvedFiles = resolveFiles(activeCwd, checkResult.config);
|
||||
const drift = scanForDrift(activeCwd, resolvedFiles, checkResult.config);
|
||||
|
||||
// 6. Emit everything the agent needs
|
||||
// 5. Emit everything the agent needs
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
serverPort: serverInfo.port,
|
||||
serverToken: serverInfo.token,
|
||||
pageFiles: resolvedFiles,
|
||||
liveConfigPath: checkResult.path,
|
||||
configDrift: drift,
|
||||
targetPath: outputTargetPath,
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
hasProduct: ctx.hasProduct,
|
||||
product: ctx.product,
|
||||
productPath: ctx.productPath,
|
||||
@@ -105,6 +149,13 @@ The agent should then:
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
function missingLiveContext(ctx) {
|
||||
const missing = [];
|
||||
if (!ctx.hasProduct) missing.push('PRODUCT.md');
|
||||
if (!ctx.hasDesign) missing.push('DESIGN.md');
|
||||
return missing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drift-heal scan. Walks the project for HTML files under common
|
||||
* page-source directories (public/, src/, app/, pages/) and reports any
|
||||
@@ -201,11 +252,11 @@ function globToRegex(pattern) {
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function runScript(name, args) {
|
||||
function runScript(name, args, options = {}) {
|
||||
const scriptPath = path.join(__dirname, name);
|
||||
const cmd = `node "${scriptPath}" ${args.map(a => `"${a}"`).join(' ')}`;
|
||||
try {
|
||||
return execSync(cmd, { encoding: 'utf-8', cwd: process.cwd(), timeout: 15_000 });
|
||||
return execSync(cmd, { encoding: 'utf-8', cwd: options.cwd || process.cwd(), timeout: 15_000 });
|
||||
} catch (err) {
|
||||
// execSync throws on non-zero exit; return stdout if any
|
||||
return err.stdout || err.message || '';
|
||||
@@ -219,10 +270,10 @@ function safeParse(out) {
|
||||
/**
|
||||
* Return { pid, port, token } for the running live server, starting one if needed.
|
||||
*/
|
||||
function ensureServerRunning() {
|
||||
function ensureServerRunning(cwd = process.cwd()) {
|
||||
// Try to reuse an existing server
|
||||
try {
|
||||
const existing = readLiveServerInfo(process.cwd())?.info;
|
||||
const existing = readLiveServerInfo(cwd)?.info;
|
||||
if (existing && existing.pid) {
|
||||
try {
|
||||
process.kill(existing.pid, 0); // throws if dead
|
||||
@@ -232,7 +283,7 @@ function ensureServerRunning() {
|
||||
} catch { /* no PID file */ }
|
||||
|
||||
// Start a new server
|
||||
const out = runScript('live-server.mjs', ['--background']);
|
||||
const out = runScript('live-server.mjs', ['--background'], { cwd });
|
||||
return safeParse(out);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ Designs and iterates production-grade frontend interfaces. Real working code, co
|
||||
|
||||
You MUST do these steps before proceeding:
|
||||
|
||||
1. Run `node .pi/skills/impeccable/scripts/context.mjs` once per session. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task.
|
||||
1. Run `node .pi/skills/impeccable/scripts/context.mjs` once per session. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .pi/skills/impeccable/scripts/context.mjs --target <path>` instead. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task.
|
||||
2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read `reference/<command>.md` next. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects.
|
||||
3. Familiarize yourself with any existing design system, conventions, and components in the code. Read at least one project file (CSS / tokens / theme / a representative component or page). **Required even when you've loaded a sub-command reference in step 2.** Don't reinvent the wheel; use what's there when it works, branch out when the UX wins.
|
||||
4. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md.
|
||||
|
||||
@@ -8,7 +8,7 @@ A running dev server with hot module replacement (Vite, Next.js, Bun, etc.), OR
|
||||
|
||||
Execute in order. No step skipped, no step reordered.
|
||||
|
||||
1. `live.mjs`: boot.
|
||||
1. `live.mjs`: boot. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .pi/skills/impeccable/scripts/live.mjs --target <path>` instead; then run the rest of this live session from the returned `projectRoot`.
|
||||
2. Open the app URL that serves `pageFile` (infer from `package.json`, docs, terminal output, or an open tab). Never use `serverPort`; it's the helper, not the app. **Cursor:** `browser_navigate` to that URL before polling; do not skip. **Other harnesses:** use the available browser tool; if the URL is uncertain, ask the user once.
|
||||
3. Poll loop with the default long timeout (600000 ms). After every event or `--reply`, run `live-poll.mjs` again immediately. Never pass a short `--timeout=`.
|
||||
|
||||
|
||||
@@ -5,11 +5,12 @@
|
||||
* init flow.
|
||||
*
|
||||
* Path resolution (first match wins):
|
||||
* 1. cwd, if PRODUCT.md or DESIGN.md is there
|
||||
* 2. .agents/context/ then docs/
|
||||
* 3. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user
|
||||
* 1. Active project root, if PRODUCT.md or DESIGN.md is there
|
||||
* 2. Active project .agents/context/ then docs/
|
||||
* 3. Monorepo root context, using the same order, as a per-file fallback
|
||||
* 4. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user
|
||||
* escape hatch, only consulted when defaults are empty
|
||||
* 4. cwd as a "nothing found" default
|
||||
* 5. Active project root as a "nothing found" default
|
||||
*
|
||||
* `resolveContextDir()` and `loadContext()` are also exported for the
|
||||
* server-side scripts (live.mjs, live-server.mjs) that need the structured
|
||||
@@ -19,10 +20,25 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseTargetOptions } from './lib/target-args.mjs';
|
||||
|
||||
const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md'];
|
||||
const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md'];
|
||||
const FALLBACK_DIRS = ['.agents/context', 'docs'];
|
||||
const MONOREPO_MARKER_FILES = ['pnpm-workspace.yaml', 'turbo.json', 'nx.json', 'lerna.json'];
|
||||
const MONOREPO_FALLBACK_PROJECT_DIRS = ['apps', 'packages'];
|
||||
const WORKSPACE_DISCOVERY_IGNORED_DIRS = new Set([
|
||||
'node_modules',
|
||||
'.git',
|
||||
'dist',
|
||||
'build',
|
||||
'.next',
|
||||
'.nuxt',
|
||||
'.svelte-kit',
|
||||
'.turbo',
|
||||
'.cache',
|
||||
'coverage',
|
||||
]);
|
||||
|
||||
// ─── Update check ──────────────────────────────────────────────────────────
|
||||
// Piggyback a lightweight skill-version check on the once-per-session boot.
|
||||
@@ -38,41 +54,600 @@ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // throttle the network poll to o
|
||||
const RENOTIFY_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000; // don't re-surface the same version for a week
|
||||
const FETCH_TIMEOUT_MS = 1200;
|
||||
|
||||
export function resolveContextDir(cwd = process.cwd()) {
|
||||
if (firstExisting(cwd, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return cwd;
|
||||
}
|
||||
for (const rel of FALLBACK_DIRS) {
|
||||
const candidate = path.resolve(cwd, rel);
|
||||
if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
const envDir = process.env.IMPECCABLE_CONTEXT_DIR;
|
||||
if (envDir && envDir.trim()) {
|
||||
const trimmed = envDir.trim();
|
||||
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
||||
}
|
||||
return cwd;
|
||||
export function resolveContextDir(cwd = process.cwd(), options = {}) {
|
||||
return resolveContext(cwd, options).contextDir;
|
||||
}
|
||||
|
||||
export function loadContext(cwd = process.cwd()) {
|
||||
const contextDir = resolveContextDir(cwd);
|
||||
const productPath = firstExisting(contextDir, PRODUCT_NAMES);
|
||||
const designPath = firstExisting(contextDir, DESIGN_NAMES);
|
||||
export function loadContext(cwd = process.cwd(), options = {}) {
|
||||
const resolved = resolveContext(cwd, options);
|
||||
const absCwd = path.resolve(cwd);
|
||||
const productPath = resolved.productPath;
|
||||
const designPath = resolved.designPath;
|
||||
const product = productPath ? safeRead(productPath) : null;
|
||||
const design = designPath ? safeRead(designPath) : null;
|
||||
return {
|
||||
hasProduct: !!product,
|
||||
product,
|
||||
productPath: productPath ? path.relative(cwd, productPath) : null,
|
||||
productPath: productPath ? path.relative(absCwd, productPath) : null,
|
||||
hasDesign: !!design,
|
||||
design,
|
||||
designPath: designPath ? path.relative(cwd, designPath) : null,
|
||||
contextDir,
|
||||
designPath: designPath ? path.relative(absCwd, designPath) : null,
|
||||
contextDir: resolved.contextDir,
|
||||
productContextDir: productPath ? path.dirname(productPath) : null,
|
||||
designContextDir: designPath ? path.dirname(designPath) : null,
|
||||
projectRoot: resolved.projectRoot,
|
||||
repoRoot: resolved.repoRoot,
|
||||
isMonorepo: resolved.isMonorepo,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveContext(cwd = process.cwd(), options = {}) {
|
||||
const absCwd = path.resolve(cwd);
|
||||
const project = resolveProject(absCwd, options);
|
||||
const projectContextDir = resolveLocalContextDir(project.projectRoot);
|
||||
const rootContextDir = project.isMonorepo && project.repoRoot !== project.projectRoot
|
||||
? resolveLocalContextDir(project.repoRoot)
|
||||
: null;
|
||||
|
||||
let productPath =
|
||||
(projectContextDir ? firstExisting(projectContextDir, PRODUCT_NAMES) : null)
|
||||
|| (rootContextDir ? firstExisting(rootContextDir, PRODUCT_NAMES) : null);
|
||||
let designPath =
|
||||
(projectContextDir ? firstExisting(projectContextDir, DESIGN_NAMES) : null)
|
||||
|| (rootContextDir ? firstExisting(rootContextDir, DESIGN_NAMES) : null);
|
||||
|
||||
let envContextDir = null;
|
||||
if (!productPath && !designPath) {
|
||||
envContextDir = resolveEnvContextDir(absCwd);
|
||||
if (envContextDir) {
|
||||
productPath = firstExisting(envContextDir, PRODUCT_NAMES);
|
||||
designPath = firstExisting(envContextDir, DESIGN_NAMES);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
contextDir: productPath
|
||||
? path.dirname(productPath)
|
||||
: designPath
|
||||
? path.dirname(designPath)
|
||||
: envContextDir || project.projectRoot,
|
||||
productPath,
|
||||
designPath,
|
||||
projectRoot: project.projectRoot,
|
||||
repoRoot: project.repoRoot,
|
||||
isMonorepo: project.isMonorepo,
|
||||
targetDir: project.targetDir,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveProjectRoot(cwd = process.cwd(), options = {}) {
|
||||
return resolveProject(cwd, options).projectRoot;
|
||||
}
|
||||
|
||||
export function resolveTargetSelection(cwd = process.cwd(), options = {}) {
|
||||
if (hasTargetOption(options)) return null;
|
||||
const project = resolveProject(cwd);
|
||||
if (
|
||||
!project.isMonorepo
|
||||
|| !project.projectRoot
|
||||
|| !project.repoRoot
|
||||
|| path.resolve(project.projectRoot) !== path.resolve(project.repoRoot)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
targetPath: null,
|
||||
projectRoot: project.projectRoot,
|
||||
repoRoot: project.repoRoot,
|
||||
targetCandidates: discoverTargetCandidates(project.repoRoot),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveProject(cwd = process.cwd(), options = {}) {
|
||||
const absCwd = path.resolve(cwd);
|
||||
const targetDir = resolveTargetDir(absCwd, options);
|
||||
let repoRoot = findMonorepoRoot(targetDir);
|
||||
if (!repoRoot && targetDir !== absCwd) {
|
||||
const cwdRepoRoot = findMonorepoRoot(absCwd);
|
||||
if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {
|
||||
repoRoot = cwdRepoRoot;
|
||||
}
|
||||
}
|
||||
if (!repoRoot) {
|
||||
return {
|
||||
targetDir,
|
||||
projectRoot: absCwd,
|
||||
repoRoot: absCwd,
|
||||
isMonorepo: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
targetDir,
|
||||
projectRoot: resolveWorkspaceProjectRoot(repoRoot, targetDir) || repoRoot,
|
||||
repoRoot,
|
||||
isMonorepo: true,
|
||||
};
|
||||
}
|
||||
|
||||
function isPathInside(candidate, root) {
|
||||
const rel = path.relative(root, candidate);
|
||||
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
|
||||
}
|
||||
|
||||
function resolveLocalContextDir(root) {
|
||||
if (firstExisting(root, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return root;
|
||||
}
|
||||
for (const rel of FALLBACK_DIRS) {
|
||||
const candidate = path.resolve(root, rel);
|
||||
if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveEnvContextDir(cwd) {
|
||||
const envDir = process.env.IMPECCABLE_CONTEXT_DIR;
|
||||
if (!envDir || !envDir.trim()) return null;
|
||||
const trimmed = envDir.trim();
|
||||
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
||||
}
|
||||
|
||||
function resolveTargetDir(cwd, options = {}) {
|
||||
const targetPath = options && typeof options === 'object' ? options.targetPath : null;
|
||||
if (!targetPath || !String(targetPath).trim()) return cwd;
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
try {
|
||||
const stat = fs.statSync(abs);
|
||||
return stat.isDirectory() ? abs : path.dirname(abs);
|
||||
} catch {
|
||||
return path.extname(abs) ? path.dirname(abs) : abs;
|
||||
}
|
||||
}
|
||||
|
||||
function findMonorepoRoot(startDir) {
|
||||
let dir = path.resolve(startDir);
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
while (true) {
|
||||
if (dir === homeDir) return null;
|
||||
if (isMonorepoRoot(dir)) return dir;
|
||||
if (hasGitBoundary(dir)) return null;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function isMonorepoRoot(dir) {
|
||||
if (readWorkspacePatterns(dir).some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) return true;
|
||||
if (!MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(dir, file)))) return false;
|
||||
return hasFallbackWorkspaceChildren(dir);
|
||||
}
|
||||
|
||||
function hasGitBoundary(dir) {
|
||||
return fs.existsSync(path.join(dir, '.git'));
|
||||
}
|
||||
|
||||
function hasFallbackWorkspaceChildren(dir) {
|
||||
for (const name of MONOREPO_FALLBACK_PROJECT_DIRS) {
|
||||
const base = path.join(dir, name);
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(base, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (entries.some((entry) => entry.isDirectory() && !isIgnoredWorkspaceDiscoveryDir(entry.name))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function discoverTargetCandidates(repoRoot) {
|
||||
const roots = new Map();
|
||||
for (const pattern of readWorkspacePatterns(repoRoot)) {
|
||||
for (const root of discoverRootsForPattern(repoRoot, pattern)) {
|
||||
roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root);
|
||||
}
|
||||
}
|
||||
if (MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(repoRoot, file)))) {
|
||||
for (const name of MONOREPO_FALLBACK_PROJECT_DIRS) {
|
||||
const base = path.join(repoRoot, name);
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(base, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
||||
const root = path.join(base, entry.name);
|
||||
roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...roots.entries()]
|
||||
.filter(([rel]) => rel && !rel.startsWith('..'))
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([rel, root]) => {
|
||||
const targetExample = findTargetExample(repoRoot, root);
|
||||
return {
|
||||
name: path.basename(root),
|
||||
path: rel,
|
||||
targetExample,
|
||||
...resolveCandidateContextSummary(repoRoot, root, targetExample),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function resolveCandidateContextSummary(repoRoot, projectRoot, targetPath) {
|
||||
const ctx = resolveContext(repoRoot, { targetPath });
|
||||
return {
|
||||
productStatus: contextSourceStatus(ctx.productPath, repoRoot, projectRoot),
|
||||
productPath: contextSourcePath(ctx.productPath, repoRoot),
|
||||
designStatus: contextSourceStatus(ctx.designPath, repoRoot, projectRoot),
|
||||
designPath: contextSourcePath(ctx.designPath, repoRoot),
|
||||
};
|
||||
}
|
||||
|
||||
function contextSourceStatus(filePath, repoRoot, projectRoot) {
|
||||
if (!filePath) return 'missing';
|
||||
const absPath = path.resolve(filePath);
|
||||
const absProjectRoot = path.resolve(projectRoot);
|
||||
const absRepoRoot = path.resolve(repoRoot);
|
||||
if (isPathInsideOrEqual(absPath, absProjectRoot)) {
|
||||
return path.dirname(absPath) === absProjectRoot ? 'child' : 'fallback';
|
||||
}
|
||||
if (absProjectRoot !== absRepoRoot && isPathInsideOrEqual(absPath, absRepoRoot)) {
|
||||
return 'inherited';
|
||||
}
|
||||
return 'fallback';
|
||||
}
|
||||
|
||||
function contextSourcePath(filePath, repoRoot) {
|
||||
if (!filePath) return null;
|
||||
const rel = path.relative(repoRoot, filePath);
|
||||
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {
|
||||
return rel.split(path.sep).join('/');
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function discoverRootsForPattern(repoRoot, rawPattern) {
|
||||
const pattern = normalizeWorkspacePattern(rawPattern);
|
||||
if (!pattern || pattern.startsWith('!')) return [];
|
||||
const segments = pattern.split('/').filter(Boolean);
|
||||
if (!segments.length) return [];
|
||||
const firstGlobIndex = segments.findIndex((segment) => segment.includes('*'));
|
||||
const literalPrefix = firstGlobIndex === -1 ? segments : segments.slice(0, firstGlobIndex);
|
||||
const base = path.join(repoRoot, ...literalPrefix);
|
||||
if (!fs.existsSync(base)) return [];
|
||||
if (segments.includes('**')) {
|
||||
const packageRoots = [];
|
||||
walkDirs(base, (dir) => {
|
||||
if (dir !== base && isCandidateProjectRoot(dir)) packageRoots.push(dir);
|
||||
});
|
||||
if (packageRoots.length) return packageRoots;
|
||||
return directChildDirs(base);
|
||||
}
|
||||
return expandSimplePattern(repoRoot, segments);
|
||||
}
|
||||
|
||||
function expandSimplePattern(repoRoot, patternSegments, index = 0, current = repoRoot) {
|
||||
if (index >= patternSegments.length) return fs.existsSync(current) ? [current] : [];
|
||||
const segment = patternSegments[index];
|
||||
if (!segment.includes('*')) {
|
||||
return expandSimplePattern(repoRoot, patternSegments, index + 1, path.join(current, segment));
|
||||
}
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(current, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const roots = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
||||
if (!segmentMatches(segment, entry.name)) continue;
|
||||
roots.push(...expandSimplePattern(repoRoot, patternSegments, index + 1, path.join(current, entry.name)));
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
function directChildDirs(dir) {
|
||||
try {
|
||||
return fs.readdirSync(dir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() && !isIgnoredWorkspaceDiscoveryDir(entry.name))
|
||||
.map((entry) => path.join(dir, entry.name));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function walkDirs(root, visit) {
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(root, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
||||
const dir = path.join(root, entry.name);
|
||||
visit(dir);
|
||||
walkDirs(dir, visit);
|
||||
}
|
||||
}
|
||||
|
||||
function isCandidateProjectRoot(dir) {
|
||||
return !!(
|
||||
fs.existsSync(path.join(dir, 'package.json'))
|
||||
|| firstExisting(dir, [...PRODUCT_NAMES, ...DESIGN_NAMES])
|
||||
|| fs.existsSync(path.join(dir, 'src'))
|
||||
|| fs.existsSync(path.join(dir, 'app'))
|
||||
|| fs.existsSync(path.join(dir, 'pages'))
|
||||
|| fs.existsSync(path.join(dir, 'public'))
|
||||
);
|
||||
}
|
||||
|
||||
function isIgnoredWorkspaceDiscoveryDir(name) {
|
||||
return name.startsWith('.') || WORKSPACE_DISCOVERY_IGNORED_DIRS.has(name);
|
||||
}
|
||||
|
||||
function findTargetExample(repoRoot, projectRoot) {
|
||||
const examples = [
|
||||
'src/App.jsx',
|
||||
'src/App.tsx',
|
||||
'src/main.jsx',
|
||||
'src/main.tsx',
|
||||
'src/index.jsx',
|
||||
'src/index.ts',
|
||||
'app/page.tsx',
|
||||
'pages/index.tsx',
|
||||
'public/index.html',
|
||||
];
|
||||
for (const rel of examples) {
|
||||
const abs = path.join(projectRoot, rel);
|
||||
if (fs.existsSync(abs)) return path.relative(repoRoot, abs).split(path.sep).join('/');
|
||||
}
|
||||
return path.relative(repoRoot, projectRoot).split(path.sep).join('/');
|
||||
}
|
||||
|
||||
function resolveWorkspaceProjectRoot(repoRoot, targetDir) {
|
||||
const rel = path.relative(repoRoot, targetDir);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return repoRoot;
|
||||
const relSegments = rel.split(path.sep).filter(Boolean);
|
||||
const patterns = readWorkspacePatterns(repoRoot);
|
||||
const excluded = isExcludedByWorkspacePattern(relSegments, patterns);
|
||||
if (!excluded) {
|
||||
for (const pattern of patterns) {
|
||||
const projectRoot = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern);
|
||||
if (projectRoot) return projectRoot;
|
||||
}
|
||||
}
|
||||
if (excluded) return repoRoot;
|
||||
if (
|
||||
relSegments.length >= 2
|
||||
&& MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0])
|
||||
) {
|
||||
return path.join(repoRoot, relSegments[0], relSegments[1]);
|
||||
}
|
||||
const nearest = nearestProjectLikeRoot(repoRoot, targetDir);
|
||||
if (nearest) return nearest;
|
||||
return repoRoot;
|
||||
}
|
||||
|
||||
function isExcludedByWorkspacePattern(relSegments, patterns) {
|
||||
return patterns.some((rawPattern) => {
|
||||
const pattern = normalizeWorkspacePattern(rawPattern);
|
||||
if (!pattern.startsWith('!')) return false;
|
||||
return workspacePatternMatchesRel(pattern.slice(1), relSegments);
|
||||
});
|
||||
}
|
||||
|
||||
function nearestProjectLikeRoot(repoRoot, targetDir) {
|
||||
let dir = path.resolve(targetDir);
|
||||
const stop = path.resolve(repoRoot);
|
||||
while (dir && dir !== stop) {
|
||||
if (
|
||||
firstExisting(dir, [...PRODUCT_NAMES, ...DESIGN_NAMES])
|
||||
|| fs.existsSync(path.join(dir, 'package.json'))
|
||||
) {
|
||||
return dir;
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function nearestPackageRootBetween(repoRoot, targetDir, stopDir) {
|
||||
let dir = path.resolve(targetDir);
|
||||
const stop = path.resolve(stopDir || repoRoot);
|
||||
const root = path.resolve(repoRoot);
|
||||
while (dir && dir !== stop && isPathInsideOrEqual(dir, root)) {
|
||||
if (fs.existsSync(path.join(dir, 'package.json'))) return dir;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPathInsideOrEqual(candidate, root) {
|
||||
return path.resolve(candidate) === path.resolve(root) || isPathInside(candidate, root);
|
||||
}
|
||||
|
||||
function workspacePatternMatchesRel(pattern, relSegments) {
|
||||
const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return false;
|
||||
if (patternSegments.includes('**')) {
|
||||
const firstGlobIndex = patternSegments.findIndex((segment) => segment.includes('*'));
|
||||
const literalPrefix = firstGlobIndex === -1
|
||||
? patternSegments
|
||||
: patternSegments.slice(0, firstGlobIndex);
|
||||
if (relSegments.length < literalPrefix.length + 1) return false;
|
||||
for (let i = 0; i < literalPrefix.length; i++) {
|
||||
if (!segmentMatches(literalPrefix[i], relSegments[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (relSegments.length < patternSegments.length) return false;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function readWorkspacePatterns(repoRoot) {
|
||||
return [
|
||||
...readPackageWorkspaces(repoRoot),
|
||||
...readPnpmWorkspaces(repoRoot),
|
||||
...readLernaWorkspaces(repoRoot),
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
function readPackageWorkspaces(repoRoot) {
|
||||
const pkg = readJson(path.join(repoRoot, 'package.json'));
|
||||
const workspaces = pkg?.workspaces;
|
||||
if (Array.isArray(workspaces)) return workspaces;
|
||||
if (Array.isArray(workspaces?.packages)) return workspaces.packages;
|
||||
return [];
|
||||
}
|
||||
|
||||
function readLernaWorkspaces(repoRoot) {
|
||||
const lerna = readJson(path.join(repoRoot, 'lerna.json'));
|
||||
return Array.isArray(lerna?.packages) ? lerna.packages : [];
|
||||
}
|
||||
|
||||
function readPnpmWorkspaces(repoRoot) {
|
||||
try {
|
||||
const body = fs.readFileSync(path.join(repoRoot, 'pnpm-workspace.yaml'), 'utf-8');
|
||||
const patterns = [];
|
||||
let inPackages = false;
|
||||
for (const line of body.split(/\r?\n/)) {
|
||||
const trimmed = stripYamlInlineComment(line).trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const flowMatch = trimmed.match(/^packages:\s*\[(.*)\]\s*$/);
|
||||
if (flowMatch) {
|
||||
patterns.push(...parseYamlFlowList(flowMatch[1]));
|
||||
inPackages = false;
|
||||
continue;
|
||||
}
|
||||
if (/^packages:\s*$/.test(trimmed)) {
|
||||
inPackages = true;
|
||||
continue;
|
||||
}
|
||||
if (inPackages && /^[A-Za-z0-9_-]+:\s*/.test(trimmed)) break;
|
||||
if (inPackages) {
|
||||
const match = trimmed.match(/^-\s*(.+)$/);
|
||||
if (match) patterns.push(unquoteYamlValue(match[1]));
|
||||
}
|
||||
}
|
||||
return patterns;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function stripYamlInlineComment(line) {
|
||||
let quote = null;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
if ((ch === '"' || ch === "'") && line[i - 1] !== '\\') {
|
||||
quote = quote === ch ? null : quote || ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === '#' && !quote) return line.slice(0, i);
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
function parseYamlFlowList(body) {
|
||||
const items = [];
|
||||
let quote = null;
|
||||
let current = '';
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
const ch = body[i];
|
||||
if ((ch === '"' || ch === "'") && body[i - 1] !== '\\') {
|
||||
quote = quote === ch ? null : quote || ch;
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === ',' && !quote) {
|
||||
const value = unquoteYamlValue(current);
|
||||
if (value) items.push(value);
|
||||
current = '';
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
}
|
||||
const value = unquoteYamlValue(current);
|
||||
if (value) items.push(value);
|
||||
return items;
|
||||
}
|
||||
|
||||
function unquoteYamlValue(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '');
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function projectRootFromWorkspacePattern(repoRoot, relSegments, rawPattern) {
|
||||
const pattern = normalizeWorkspacePattern(rawPattern);
|
||||
if (!pattern || pattern.startsWith('!')) return null;
|
||||
const patternSegments = pattern.split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return null;
|
||||
if (patternSegments.includes('**')) {
|
||||
return projectRootFromDoubleStarPattern(repoRoot, relSegments, patternSegments);
|
||||
}
|
||||
if (relSegments.length < patternSegments.length) return null;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return null;
|
||||
}
|
||||
return path.join(repoRoot, ...relSegments.slice(0, patternSegments.length));
|
||||
}
|
||||
|
||||
function projectRootFromDoubleStarPattern(repoRoot, relSegments, patternSegments) {
|
||||
const firstGlobIndex = patternSegments.findIndex((segment) => segment.includes('*'));
|
||||
const literalPrefix = firstGlobIndex === -1
|
||||
? patternSegments
|
||||
: patternSegments.slice(0, firstGlobIndex);
|
||||
if (relSegments.length < literalPrefix.length + 1) return null;
|
||||
for (let i = 0; i < literalPrefix.length; i++) {
|
||||
if (!segmentMatches(literalPrefix[i], relSegments[i])) return null;
|
||||
}
|
||||
const prefixDir = path.join(repoRoot, ...literalPrefix);
|
||||
const targetDir = path.join(repoRoot, ...relSegments);
|
||||
const packageRoot = nearestPackageRootBetween(repoRoot, targetDir, prefixDir);
|
||||
if (packageRoot) return packageRoot;
|
||||
return path.join(repoRoot, ...relSegments.slice(0, literalPrefix.length + 1));
|
||||
}
|
||||
|
||||
function normalizeWorkspacePattern(pattern) {
|
||||
return String(pattern || '')
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '')
|
||||
.replace(/^\.\//, '')
|
||||
.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function segmentMatches(patternSegment, relSegment) {
|
||||
if (patternSegment === '*') return true;
|
||||
if (!patternSegment.includes('*')) return patternSegment === relSegment;
|
||||
const re = new RegExp(`^${escapeRegExp(patternSegment).replace(/\\\*/g, '[^/]*')}$`);
|
||||
return re.test(relSegment);
|
||||
}
|
||||
|
||||
function firstExisting(dir, names) {
|
||||
for (const name of names) {
|
||||
const abs = path.join(dir, name);
|
||||
@@ -89,6 +664,10 @@ function safeRead(p) {
|
||||
}
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the register (`brand` or `product`) out of PRODUCT.md by looking
|
||||
* for a `## Register` section and reading the first non-empty line that
|
||||
@@ -233,7 +812,24 @@ async function computeUpdateDirective(now = Date.now()) {
|
||||
}
|
||||
|
||||
async function cli() {
|
||||
const ctx = loadContext(process.cwd());
|
||||
let cliOptions;
|
||||
try {
|
||||
cliOptions = parseCliOptions(process.argv.slice(2));
|
||||
} catch (err) {
|
||||
if (err?.name === 'TargetArgError') {
|
||||
process.stderr.write(`${err.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const targetProvided = hasTargetOption(cliOptions);
|
||||
const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null;
|
||||
const selection = resolveTargetSelection(process.cwd(), cliOptions);
|
||||
if (selection) {
|
||||
process.stdout.write(buildTargetSelectionDirective(selection) + '\n');
|
||||
process.exit(0);
|
||||
}
|
||||
const ctx = loadContext(process.cwd(), cliOptions);
|
||||
const updateDirective = await computeUpdateDirective();
|
||||
|
||||
if (!ctx.hasProduct) {
|
||||
@@ -244,6 +840,10 @@ async function cli() {
|
||||
'Stop the current task, load reference/init.md, and follow its ' +
|
||||
'instructions to write PRODUCT.md before resuming.',
|
||||
];
|
||||
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
|
||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||
parts.push(buildMissingTargetDirective());
|
||||
}
|
||||
if (updateDirective) parts.push(updateDirective);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
process.exit(0);
|
||||
@@ -252,6 +852,10 @@ async function cli() {
|
||||
if (ctx.hasDesign) {
|
||||
parts.push(`# DESIGN.md\n\n${ctx.design.trim()}`);
|
||||
}
|
||||
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
|
||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||
parts.push(buildMissingTargetDirective());
|
||||
}
|
||||
const register = extractRegister(ctx.product);
|
||||
const next = register
|
||||
? `NEXT STEP: This project's register is \`${register}\`. You MUST now read \`reference/${register}.md\` before producing any design output.`
|
||||
@@ -261,6 +865,60 @@ async function cli() {
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
}
|
||||
|
||||
function parseCliOptions(args) {
|
||||
return parseTargetOptions(args, { strict: true });
|
||||
}
|
||||
|
||||
function hasTargetOption(options) {
|
||||
return !!(options && typeof options.targetPath === 'string' && options.targetPath.trim());
|
||||
}
|
||||
|
||||
function pathExistsForTarget(cwd, targetPath) {
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
return fs.existsSync(abs);
|
||||
}
|
||||
|
||||
function buildResolvedContextDirective(ctx, options, { targetExists = null } = {}) {
|
||||
const targetPath = hasTargetOption(options) ? options.targetPath : null;
|
||||
return `RESOLVED_CONTEXT:\n${JSON.stringify({
|
||||
targetPath,
|
||||
...(targetPath ? { targetExists } : {}),
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
productPath: ctx.productPath,
|
||||
designPath: ctx.designPath,
|
||||
}, null, 2)}`;
|
||||
}
|
||||
|
||||
function shouldWarnMissingTarget(ctx, targetProvided, targetExists = null) {
|
||||
if (ctx.isMonorepo && targetProvided && targetExists === false) return true;
|
||||
return !!(
|
||||
ctx.isMonorepo
|
||||
&& (!targetProvided || targetExists === false)
|
||||
&& ctx.projectRoot
|
||||
&& ctx.repoRoot
|
||||
&& path.resolve(ctx.projectRoot) === path.resolve(ctx.repoRoot)
|
||||
);
|
||||
}
|
||||
|
||||
function buildMissingTargetDirective() {
|
||||
const script = process.argv[1] || 'context.mjs';
|
||||
return (
|
||||
'MONOREPO_TARGET_REQUIRED: This is a monorepo and context.mjs ran without --target. ' +
|
||||
'If the user named a file, route, or child app, do not answer from this output. ' +
|
||||
`Rerun \`node ${script} --target <path>\` and answer from that run's RESOLVED_CONTEXT fields.`
|
||||
);
|
||||
}
|
||||
|
||||
function buildTargetSelectionDirective(selection) {
|
||||
return (
|
||||
`TARGET_SELECTION_REQUIRED:\n${JSON.stringify(selection, null, 2)}\n\n` +
|
||||
'Show each app with its productStatus/productPath and designStatus/designPath so the user can see child overrides, inherited root files, fallback files, or missing files before choosing. ' +
|
||||
'Ask the user which app Impeccable should use, then rerun Impeccable helper commands from that child app cwd using this same scripts directory. ' +
|
||||
'Use `--target <path>` only as a fallback when changing cwd is not possible, or when the user explicitly named a file/path.'
|
||||
);
|
||||
}
|
||||
|
||||
// Run cli() only when this module is the entry point. Compare realpaths
|
||||
// rather than endsWith(): a loose suffix match also fires for unrelated
|
||||
// scripts like `load-context.mjs`, and realpath tolerates symlinked
|
||||
|
||||
@@ -1,50 +1,52 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { resolveProjectRoot } from '../context.mjs';
|
||||
|
||||
export const IMPECCABLE_DIR = '.impeccable';
|
||||
export const LIVE_DIR = 'live';
|
||||
export const CRITIQUE_DIR = 'critique';
|
||||
|
||||
export function getImpeccableDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, IMPECCABLE_DIR);
|
||||
export function getImpeccableDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), IMPECCABLE_DIR);
|
||||
}
|
||||
|
||||
export function getDesignSidecarPath(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), 'design.json');
|
||||
export function getDesignSidecarPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), 'design.json');
|
||||
}
|
||||
|
||||
export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd) {
|
||||
export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd, options = {}) {
|
||||
const projectRoot = resolveProjectRoot(cwd, options);
|
||||
const candidates = [
|
||||
getDesignSidecarPath(cwd),
|
||||
path.join(cwd, 'DESIGN.json'),
|
||||
getDesignSidecarPath(cwd, options),
|
||||
path.join(projectRoot, 'DESIGN.json'),
|
||||
];
|
||||
const contextLegacy = path.join(contextDir, 'DESIGN.json');
|
||||
if (!candidates.includes(contextLegacy)) candidates.push(contextLegacy);
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) {
|
||||
return firstExisting(getDesignSidecarCandidates(cwd, contextDir));
|
||||
export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd, options = {}) {
|
||||
return firstExisting(getDesignSidecarCandidates(cwd, contextDir, options));
|
||||
}
|
||||
|
||||
export function getLiveDir(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), LIVE_DIR);
|
||||
export function getLiveDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), LIVE_DIR);
|
||||
}
|
||||
|
||||
export function getLiveConfigPath(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'config.json');
|
||||
export function getLiveConfigPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'config.json');
|
||||
}
|
||||
|
||||
export function getLegacyLiveConfigPath(scriptsDir) {
|
||||
return path.join(scriptsDir, 'config.json');
|
||||
}
|
||||
|
||||
export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env } = {}) {
|
||||
export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env, targetPath } = {}) {
|
||||
if (env.IMPECCABLE_LIVE_CONFIG && env.IMPECCABLE_LIVE_CONFIG.trim()) {
|
||||
const configured = env.IMPECCABLE_LIVE_CONFIG.trim();
|
||||
return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured);
|
||||
}
|
||||
const primary = getLiveConfigPath(cwd);
|
||||
const primary = getLiveConfigPath(cwd, { targetPath });
|
||||
if (fs.existsSync(primary)) return primary;
|
||||
if (scriptsDir) {
|
||||
const legacy = getLegacyLiveConfigPath(scriptsDir);
|
||||
@@ -53,16 +55,16 @@ export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = p
|
||||
return primary;
|
||||
}
|
||||
|
||||
export function getLiveServerPath(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'server.json');
|
||||
export function getLiveServerPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'server.json');
|
||||
}
|
||||
|
||||
export function getLegacyLiveServerPath(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live.json');
|
||||
export function getLegacyLiveServerPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live.json');
|
||||
}
|
||||
|
||||
export function readLiveServerInfo(cwd = process.cwd()) {
|
||||
for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) {
|
||||
export function readLiveServerInfo(cwd = process.cwd(), options = {}) {
|
||||
for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) {
|
||||
try {
|
||||
const info = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
if (info && typeof info.pid === 'number' && !isLiveServerPidReachable(info.pid)) {
|
||||
@@ -88,37 +90,37 @@ export function isLiveServerPidReachable(pid) {
|
||||
}
|
||||
}
|
||||
|
||||
export function writeLiveServerInfo(cwd = process.cwd(), info) {
|
||||
const filePath = getLiveServerPath(cwd);
|
||||
export function writeLiveServerInfo(cwd = process.cwd(), info, options = {}) {
|
||||
const filePath = getLiveServerPath(cwd, options);
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(info));
|
||||
return filePath;
|
||||
}
|
||||
|
||||
export function removeLiveServerInfo(cwd = process.cwd()) {
|
||||
for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) {
|
||||
export function removeLiveServerInfo(cwd = process.cwd(), options = {}) {
|
||||
for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) {
|
||||
try { fs.unlinkSync(filePath); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
export function getLiveSessionsDir(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'sessions');
|
||||
export function getLiveSessionsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'sessions');
|
||||
}
|
||||
|
||||
export function getLegacyLiveSessionsDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live', 'sessions');
|
||||
export function getLegacyLiveSessionsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'sessions');
|
||||
}
|
||||
|
||||
export function getLiveAnnotationsDir(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'annotations');
|
||||
export function getLiveAnnotationsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'annotations');
|
||||
}
|
||||
|
||||
export function getCritiqueDir(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), CRITIQUE_DIR);
|
||||
export function getCritiqueDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), CRITIQUE_DIR);
|
||||
}
|
||||
|
||||
export function getLegacyLiveAnnotationsDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live', 'annotations');
|
||||
export function getLegacyLiveAnnotationsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'annotations');
|
||||
}
|
||||
|
||||
function firstExisting(paths) {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
class TargetArgError extends Error {
|
||||
constructor(message, code) {
|
||||
super(message);
|
||||
this.name = 'TargetArgError';
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseTargetPath(args = [], { strict = false } = {}) {
|
||||
let targetPath = null;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = String(args[i]);
|
||||
if (arg === '--target' || arg === '-t') {
|
||||
const next = args[i + 1];
|
||||
if (next && !String(next).startsWith('-')) {
|
||||
targetPath = String(next);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (strict) {
|
||||
throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith('--target=')) {
|
||||
const value = arg.slice('--target='.length);
|
||||
if (value) {
|
||||
targetPath = value;
|
||||
continue;
|
||||
}
|
||||
if (strict) {
|
||||
throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING');
|
||||
}
|
||||
}
|
||||
}
|
||||
return targetPath;
|
||||
}
|
||||
|
||||
export function parseTargetOptions(args = [], options = {}) {
|
||||
const targetPath = parseTargetPath(args, options);
|
||||
return targetPath ? { targetPath } : {};
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import path from 'node:path';
|
||||
import net from 'node:net';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseDesignMd } from './lib/design-parser.mjs';
|
||||
import { resolveContextDir } from './context.mjs';
|
||||
import { loadContext } from './context.mjs';
|
||||
import {
|
||||
assembleLiveBrowserScript,
|
||||
assertLiveBrowserScriptParts,
|
||||
@@ -55,7 +55,11 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
// PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
|
||||
// DESIGN sidecar is project-local at .impeccable/design.json, with legacy
|
||||
// DESIGN.json fallback for existing projects.
|
||||
const CONTEXT_DIR = resolveContextDir(process.cwd());
|
||||
const PROJECT_CONTEXT = loadContext(process.cwd());
|
||||
const CONTEXT_DIR = PROJECT_CONTEXT.contextDir;
|
||||
const DESIGN_MD_PATH = PROJECT_CONTEXT.designPath
|
||||
? path.resolve(process.cwd(), PROJECT_CONTEXT.designPath)
|
||||
: null;
|
||||
const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway
|
||||
const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s
|
||||
|
||||
@@ -371,10 +375,7 @@ function hasProjectContext() {
|
||||
// PRODUCT.md carries brand voice / anti-references — that's what determines
|
||||
// whether variants are brand-aware. DESIGN.md (visual tokens) is a separate
|
||||
// concern, surfaced by the design panel's own empty state.
|
||||
try {
|
||||
fs.accessSync(path.join(CONTEXT_DIR, 'PRODUCT.md'), fs.constants.R_OK);
|
||||
return true;
|
||||
} catch { return false; }
|
||||
return !!PROJECT_CONTEXT.hasProduct;
|
||||
}
|
||||
|
||||
function statOrNull(filePath) {
|
||||
@@ -549,8 +550,8 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
const token = url.searchParams.get('token');
|
||||
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
|
||||
|
||||
const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md');
|
||||
const jsonPath = resolveDesignSidecarPath(process.cwd(), CONTEXT_DIR) || getDesignSidecarPath(process.cwd());
|
||||
const mdPath = DESIGN_MD_PATH;
|
||||
const jsonPath = resolveDesignSidecarPath(process.cwd(), PROJECT_CONTEXT.designContextDir || CONTEXT_DIR) || getDesignSidecarPath(process.cwd());
|
||||
const mdStat = statOrNull(mdPath);
|
||||
const jsonStat = statOrNull(jsonPath);
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import path from 'node:path';
|
||||
import { resolveProjectRoot } from './context.mjs';
|
||||
import { parseTargetPath } from './lib/target-args.mjs';
|
||||
|
||||
export function resolveLiveTarget(cwd = process.cwd(), args = []) {
|
||||
const originalCwd = path.resolve(cwd);
|
||||
let targetPath = null;
|
||||
try {
|
||||
targetPath = parseTargetPath(args, { strict: true });
|
||||
} catch (err) {
|
||||
if (err?.name === 'TargetArgError') {
|
||||
process.stderr.write(`${err.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const absoluteTargetPath = targetPath
|
||||
? path.isAbsolute(targetPath) ? targetPath : path.resolve(originalCwd, targetPath)
|
||||
: null;
|
||||
const projectRoot = targetPath
|
||||
? resolveProjectRoot(originalCwd, { targetPath: absoluteTargetPath })
|
||||
: originalCwd;
|
||||
return {
|
||||
originalCwd,
|
||||
projectRoot,
|
||||
targetPath,
|
||||
absoluteTargetPath,
|
||||
targetOptions: absoluteTargetPath ? { targetPath: absoluteTargetPath } : {},
|
||||
};
|
||||
}
|
||||
@@ -21,14 +21,16 @@ import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadContext } from './context.mjs';
|
||||
import { loadContext, resolveTargetSelection } from './context.mjs';
|
||||
import { resolveFiles } from './live-inject.mjs';
|
||||
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
|
||||
import { resolveLiveTarget } from './live-target.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
async function liveCli() {
|
||||
const args = process.argv.slice(2);
|
||||
const liveTarget = resolveLiveTarget(process.cwd(), args);
|
||||
|
||||
if (args.includes('--help') || args.includes('-h')) {
|
||||
console.log(`Usage: node live.mjs
|
||||
@@ -38,37 +40,78 @@ Prepare everything for live variant mode in a single command:
|
||||
- Starts (or reuses) the live server in the background
|
||||
- Injects the browser script tag
|
||||
- Reads PRODUCT.md / DESIGN.md for project context
|
||||
- In monorepos, choose a child app first; --target <path> is the fallback/manual path
|
||||
|
||||
On success, prints a JSON blob with:
|
||||
{ ok, serverPort, serverToken, pageFile, hasContext, context }
|
||||
{ ok, serverPort, serverToken, pageFiles, projectRoot, repoRoot, targetPath, productPath, designPath }
|
||||
|
||||
On target_selection_required, prints:
|
||||
{ ok: false, error: "target_selection_required", targetCandidates }
|
||||
|
||||
On config_missing, prints:
|
||||
{ ok: false, error: "config_missing", configPath, hint }
|
||||
|
||||
The agent should then:
|
||||
1. If config_missing, create the config and re-run this script
|
||||
2. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort)
|
||||
3. Enter the poll loop: node live-poll.mjs`);
|
||||
1. If target_selection_required, ask which app to use and rerun from that child cwd
|
||||
2. If config_missing, create the config and re-run this script
|
||||
3. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort)
|
||||
4. Enter the poll loop: node live-poll.mjs`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const targetSelection = resolveTargetSelection(liveTarget.originalCwd, liveTarget.targetOptions);
|
||||
if (targetSelection) {
|
||||
console.log(JSON.stringify({
|
||||
ok: false,
|
||||
error: 'target_selection_required',
|
||||
...targetSelection,
|
||||
hint: 'Ask the user which app Impeccable should use, then rerun live from that child app cwd. Use --target <path> only as a fallback or explicit path diagnostic.',
|
||||
}, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const ctx = loadContext(liveTarget.originalCwd, liveTarget.targetOptions);
|
||||
const activeCwd = ctx.projectRoot;
|
||||
const outputTargetPath = liveTarget.targetPath || null;
|
||||
|
||||
const missingContext = missingLiveContext(ctx);
|
||||
if (missingContext.length > 0) {
|
||||
console.log(JSON.stringify({
|
||||
ok: false,
|
||||
error: 'context_missing',
|
||||
missing: missingContext,
|
||||
nextCommand: missingContext.includes('PRODUCT.md') ? 'init' : 'document',
|
||||
targetPath: outputTargetPath,
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
productPath: ctx.productPath,
|
||||
designPath: ctx.designPath,
|
||||
}, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 1. Check config (fail fast if missing — no point starting anything else)
|
||||
const checkOut = runScript('live-inject.mjs', ['--check']);
|
||||
const checkOut = runScript('live-inject.mjs', ['--check'], { cwd: activeCwd });
|
||||
const checkResult = safeParse(checkOut);
|
||||
if (!checkResult || !checkResult.ok) {
|
||||
console.log(JSON.stringify(checkResult || { ok: false, error: 'check_failed', raw: checkOut }));
|
||||
console.log(JSON.stringify({
|
||||
...(checkResult || { ok: false, error: 'check_failed', raw: checkOut }),
|
||||
targetPath: outputTargetPath,
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
}));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 2. Start server (or reuse existing)
|
||||
const serverInfo = ensureServerRunning();
|
||||
const serverInfo = ensureServerRunning(activeCwd);
|
||||
if (!serverInfo) {
|
||||
console.log(JSON.stringify({ ok: false, error: 'server_start_failed' }));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 3. Inject the script tag at the current port
|
||||
const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)]);
|
||||
const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)], { cwd: activeCwd });
|
||||
const injectResult = safeParse(injectOut);
|
||||
if (!injectResult || !injectResult.ok) {
|
||||
console.log(JSON.stringify({
|
||||
@@ -80,22 +123,23 @@ The agent should then:
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 4. Load PRODUCT.md + DESIGN.md context.
|
||||
const ctx = loadContext(process.cwd());
|
||||
|
||||
// 5. Compute drift-heal: compare resolved inject targets against the
|
||||
// 4. 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);
|
||||
const resolvedFiles = resolveFiles(activeCwd, checkResult.config);
|
||||
const drift = scanForDrift(activeCwd, resolvedFiles, checkResult.config);
|
||||
|
||||
// 6. Emit everything the agent needs
|
||||
// 5. Emit everything the agent needs
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
serverPort: serverInfo.port,
|
||||
serverToken: serverInfo.token,
|
||||
pageFiles: resolvedFiles,
|
||||
liveConfigPath: checkResult.path,
|
||||
configDrift: drift,
|
||||
targetPath: outputTargetPath,
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
hasProduct: ctx.hasProduct,
|
||||
product: ctx.product,
|
||||
productPath: ctx.productPath,
|
||||
@@ -105,6 +149,13 @@ The agent should then:
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
function missingLiveContext(ctx) {
|
||||
const missing = [];
|
||||
if (!ctx.hasProduct) missing.push('PRODUCT.md');
|
||||
if (!ctx.hasDesign) missing.push('DESIGN.md');
|
||||
return missing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drift-heal scan. Walks the project for HTML files under common
|
||||
* page-source directories (public/, src/, app/, pages/) and reports any
|
||||
@@ -201,11 +252,11 @@ function globToRegex(pattern) {
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function runScript(name, args) {
|
||||
function runScript(name, args, options = {}) {
|
||||
const scriptPath = path.join(__dirname, name);
|
||||
const cmd = `node "${scriptPath}" ${args.map(a => `"${a}"`).join(' ')}`;
|
||||
try {
|
||||
return execSync(cmd, { encoding: 'utf-8', cwd: process.cwd(), timeout: 15_000 });
|
||||
return execSync(cmd, { encoding: 'utf-8', cwd: options.cwd || process.cwd(), timeout: 15_000 });
|
||||
} catch (err) {
|
||||
// execSync throws on non-zero exit; return stdout if any
|
||||
return err.stdout || err.message || '';
|
||||
@@ -219,10 +270,10 @@ function safeParse(out) {
|
||||
/**
|
||||
* Return { pid, port, token } for the running live server, starting one if needed.
|
||||
*/
|
||||
function ensureServerRunning() {
|
||||
function ensureServerRunning(cwd = process.cwd()) {
|
||||
// Try to reuse an existing server
|
||||
try {
|
||||
const existing = readLiveServerInfo(process.cwd())?.info;
|
||||
const existing = readLiveServerInfo(cwd)?.info;
|
||||
if (existing && existing.pid) {
|
||||
try {
|
||||
process.kill(existing.pid, 0); // throws if dead
|
||||
@@ -232,7 +283,7 @@ function ensureServerRunning() {
|
||||
} catch { /* no PID file */ }
|
||||
|
||||
// Start a new server
|
||||
const out = runScript('live-server.mjs', ['--background']);
|
||||
const out = runScript('live-server.mjs', ['--background'], { cwd });
|
||||
return safeParse(out);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ Designs and iterates production-grade frontend interfaces. Real working code, co
|
||||
|
||||
You MUST do these steps before proceeding:
|
||||
|
||||
1. Run `node .qoder/skills/impeccable/scripts/context.mjs` once per session. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task.
|
||||
1. Run `node .qoder/skills/impeccable/scripts/context.mjs` once per session. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .qoder/skills/impeccable/scripts/context.mjs --target <path>` instead. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task.
|
||||
2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read `reference/<command>.md` next. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects.
|
||||
3. Familiarize yourself with any existing design system, conventions, and components in the code. Read at least one project file (CSS / tokens / theme / a representative component or page). **Required even when you've loaded a sub-command reference in step 2.** Don't reinvent the wheel; use what's there when it works, branch out when the UX wins.
|
||||
4. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md.
|
||||
|
||||
@@ -8,7 +8,7 @@ A running dev server with hot module replacement (Vite, Next.js, Bun, etc.), OR
|
||||
|
||||
Execute in order. No step skipped, no step reordered.
|
||||
|
||||
1. `live.mjs`: boot.
|
||||
1. `live.mjs`: boot. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .qoder/skills/impeccable/scripts/live.mjs --target <path>` instead; then run the rest of this live session from the returned `projectRoot`.
|
||||
2. Open the app URL that serves `pageFile` (infer from `package.json`, docs, terminal output, or an open tab). Never use `serverPort`; it's the helper, not the app. **Cursor:** `browser_navigate` to that URL before polling; do not skip. **Other harnesses:** use the available browser tool; if the URL is uncertain, ask the user once.
|
||||
3. Poll loop with the default long timeout (600000 ms). After every event or `--reply`, run `live-poll.mjs` again immediately. Never pass a short `--timeout=`.
|
||||
|
||||
|
||||
@@ -5,11 +5,12 @@
|
||||
* init flow.
|
||||
*
|
||||
* Path resolution (first match wins):
|
||||
* 1. cwd, if PRODUCT.md or DESIGN.md is there
|
||||
* 2. .agents/context/ then docs/
|
||||
* 3. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user
|
||||
* 1. Active project root, if PRODUCT.md or DESIGN.md is there
|
||||
* 2. Active project .agents/context/ then docs/
|
||||
* 3. Monorepo root context, using the same order, as a per-file fallback
|
||||
* 4. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user
|
||||
* escape hatch, only consulted when defaults are empty
|
||||
* 4. cwd as a "nothing found" default
|
||||
* 5. Active project root as a "nothing found" default
|
||||
*
|
||||
* `resolveContextDir()` and `loadContext()` are also exported for the
|
||||
* server-side scripts (live.mjs, live-server.mjs) that need the structured
|
||||
@@ -19,10 +20,25 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseTargetOptions } from './lib/target-args.mjs';
|
||||
|
||||
const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md'];
|
||||
const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md'];
|
||||
const FALLBACK_DIRS = ['.agents/context', 'docs'];
|
||||
const MONOREPO_MARKER_FILES = ['pnpm-workspace.yaml', 'turbo.json', 'nx.json', 'lerna.json'];
|
||||
const MONOREPO_FALLBACK_PROJECT_DIRS = ['apps', 'packages'];
|
||||
const WORKSPACE_DISCOVERY_IGNORED_DIRS = new Set([
|
||||
'node_modules',
|
||||
'.git',
|
||||
'dist',
|
||||
'build',
|
||||
'.next',
|
||||
'.nuxt',
|
||||
'.svelte-kit',
|
||||
'.turbo',
|
||||
'.cache',
|
||||
'coverage',
|
||||
]);
|
||||
|
||||
// ─── Update check ──────────────────────────────────────────────────────────
|
||||
// Piggyback a lightweight skill-version check on the once-per-session boot.
|
||||
@@ -38,41 +54,600 @@ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // throttle the network poll to o
|
||||
const RENOTIFY_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000; // don't re-surface the same version for a week
|
||||
const FETCH_TIMEOUT_MS = 1200;
|
||||
|
||||
export function resolveContextDir(cwd = process.cwd()) {
|
||||
if (firstExisting(cwd, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return cwd;
|
||||
}
|
||||
for (const rel of FALLBACK_DIRS) {
|
||||
const candidate = path.resolve(cwd, rel);
|
||||
if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
const envDir = process.env.IMPECCABLE_CONTEXT_DIR;
|
||||
if (envDir && envDir.trim()) {
|
||||
const trimmed = envDir.trim();
|
||||
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
||||
}
|
||||
return cwd;
|
||||
export function resolveContextDir(cwd = process.cwd(), options = {}) {
|
||||
return resolveContext(cwd, options).contextDir;
|
||||
}
|
||||
|
||||
export function loadContext(cwd = process.cwd()) {
|
||||
const contextDir = resolveContextDir(cwd);
|
||||
const productPath = firstExisting(contextDir, PRODUCT_NAMES);
|
||||
const designPath = firstExisting(contextDir, DESIGN_NAMES);
|
||||
export function loadContext(cwd = process.cwd(), options = {}) {
|
||||
const resolved = resolveContext(cwd, options);
|
||||
const absCwd = path.resolve(cwd);
|
||||
const productPath = resolved.productPath;
|
||||
const designPath = resolved.designPath;
|
||||
const product = productPath ? safeRead(productPath) : null;
|
||||
const design = designPath ? safeRead(designPath) : null;
|
||||
return {
|
||||
hasProduct: !!product,
|
||||
product,
|
||||
productPath: productPath ? path.relative(cwd, productPath) : null,
|
||||
productPath: productPath ? path.relative(absCwd, productPath) : null,
|
||||
hasDesign: !!design,
|
||||
design,
|
||||
designPath: designPath ? path.relative(cwd, designPath) : null,
|
||||
contextDir,
|
||||
designPath: designPath ? path.relative(absCwd, designPath) : null,
|
||||
contextDir: resolved.contextDir,
|
||||
productContextDir: productPath ? path.dirname(productPath) : null,
|
||||
designContextDir: designPath ? path.dirname(designPath) : null,
|
||||
projectRoot: resolved.projectRoot,
|
||||
repoRoot: resolved.repoRoot,
|
||||
isMonorepo: resolved.isMonorepo,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveContext(cwd = process.cwd(), options = {}) {
|
||||
const absCwd = path.resolve(cwd);
|
||||
const project = resolveProject(absCwd, options);
|
||||
const projectContextDir = resolveLocalContextDir(project.projectRoot);
|
||||
const rootContextDir = project.isMonorepo && project.repoRoot !== project.projectRoot
|
||||
? resolveLocalContextDir(project.repoRoot)
|
||||
: null;
|
||||
|
||||
let productPath =
|
||||
(projectContextDir ? firstExisting(projectContextDir, PRODUCT_NAMES) : null)
|
||||
|| (rootContextDir ? firstExisting(rootContextDir, PRODUCT_NAMES) : null);
|
||||
let designPath =
|
||||
(projectContextDir ? firstExisting(projectContextDir, DESIGN_NAMES) : null)
|
||||
|| (rootContextDir ? firstExisting(rootContextDir, DESIGN_NAMES) : null);
|
||||
|
||||
let envContextDir = null;
|
||||
if (!productPath && !designPath) {
|
||||
envContextDir = resolveEnvContextDir(absCwd);
|
||||
if (envContextDir) {
|
||||
productPath = firstExisting(envContextDir, PRODUCT_NAMES);
|
||||
designPath = firstExisting(envContextDir, DESIGN_NAMES);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
contextDir: productPath
|
||||
? path.dirname(productPath)
|
||||
: designPath
|
||||
? path.dirname(designPath)
|
||||
: envContextDir || project.projectRoot,
|
||||
productPath,
|
||||
designPath,
|
||||
projectRoot: project.projectRoot,
|
||||
repoRoot: project.repoRoot,
|
||||
isMonorepo: project.isMonorepo,
|
||||
targetDir: project.targetDir,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveProjectRoot(cwd = process.cwd(), options = {}) {
|
||||
return resolveProject(cwd, options).projectRoot;
|
||||
}
|
||||
|
||||
export function resolveTargetSelection(cwd = process.cwd(), options = {}) {
|
||||
if (hasTargetOption(options)) return null;
|
||||
const project = resolveProject(cwd);
|
||||
if (
|
||||
!project.isMonorepo
|
||||
|| !project.projectRoot
|
||||
|| !project.repoRoot
|
||||
|| path.resolve(project.projectRoot) !== path.resolve(project.repoRoot)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
targetPath: null,
|
||||
projectRoot: project.projectRoot,
|
||||
repoRoot: project.repoRoot,
|
||||
targetCandidates: discoverTargetCandidates(project.repoRoot),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveProject(cwd = process.cwd(), options = {}) {
|
||||
const absCwd = path.resolve(cwd);
|
||||
const targetDir = resolveTargetDir(absCwd, options);
|
||||
let repoRoot = findMonorepoRoot(targetDir);
|
||||
if (!repoRoot && targetDir !== absCwd) {
|
||||
const cwdRepoRoot = findMonorepoRoot(absCwd);
|
||||
if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {
|
||||
repoRoot = cwdRepoRoot;
|
||||
}
|
||||
}
|
||||
if (!repoRoot) {
|
||||
return {
|
||||
targetDir,
|
||||
projectRoot: absCwd,
|
||||
repoRoot: absCwd,
|
||||
isMonorepo: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
targetDir,
|
||||
projectRoot: resolveWorkspaceProjectRoot(repoRoot, targetDir) || repoRoot,
|
||||
repoRoot,
|
||||
isMonorepo: true,
|
||||
};
|
||||
}
|
||||
|
||||
function isPathInside(candidate, root) {
|
||||
const rel = path.relative(root, candidate);
|
||||
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
|
||||
}
|
||||
|
||||
function resolveLocalContextDir(root) {
|
||||
if (firstExisting(root, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return root;
|
||||
}
|
||||
for (const rel of FALLBACK_DIRS) {
|
||||
const candidate = path.resolve(root, rel);
|
||||
if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveEnvContextDir(cwd) {
|
||||
const envDir = process.env.IMPECCABLE_CONTEXT_DIR;
|
||||
if (!envDir || !envDir.trim()) return null;
|
||||
const trimmed = envDir.trim();
|
||||
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
||||
}
|
||||
|
||||
function resolveTargetDir(cwd, options = {}) {
|
||||
const targetPath = options && typeof options === 'object' ? options.targetPath : null;
|
||||
if (!targetPath || !String(targetPath).trim()) return cwd;
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
try {
|
||||
const stat = fs.statSync(abs);
|
||||
return stat.isDirectory() ? abs : path.dirname(abs);
|
||||
} catch {
|
||||
return path.extname(abs) ? path.dirname(abs) : abs;
|
||||
}
|
||||
}
|
||||
|
||||
function findMonorepoRoot(startDir) {
|
||||
let dir = path.resolve(startDir);
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
while (true) {
|
||||
if (dir === homeDir) return null;
|
||||
if (isMonorepoRoot(dir)) return dir;
|
||||
if (hasGitBoundary(dir)) return null;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function isMonorepoRoot(dir) {
|
||||
if (readWorkspacePatterns(dir).some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) return true;
|
||||
if (!MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(dir, file)))) return false;
|
||||
return hasFallbackWorkspaceChildren(dir);
|
||||
}
|
||||
|
||||
function hasGitBoundary(dir) {
|
||||
return fs.existsSync(path.join(dir, '.git'));
|
||||
}
|
||||
|
||||
function hasFallbackWorkspaceChildren(dir) {
|
||||
for (const name of MONOREPO_FALLBACK_PROJECT_DIRS) {
|
||||
const base = path.join(dir, name);
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(base, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (entries.some((entry) => entry.isDirectory() && !isIgnoredWorkspaceDiscoveryDir(entry.name))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function discoverTargetCandidates(repoRoot) {
|
||||
const roots = new Map();
|
||||
for (const pattern of readWorkspacePatterns(repoRoot)) {
|
||||
for (const root of discoverRootsForPattern(repoRoot, pattern)) {
|
||||
roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root);
|
||||
}
|
||||
}
|
||||
if (MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(repoRoot, file)))) {
|
||||
for (const name of MONOREPO_FALLBACK_PROJECT_DIRS) {
|
||||
const base = path.join(repoRoot, name);
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(base, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
||||
const root = path.join(base, entry.name);
|
||||
roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...roots.entries()]
|
||||
.filter(([rel]) => rel && !rel.startsWith('..'))
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([rel, root]) => {
|
||||
const targetExample = findTargetExample(repoRoot, root);
|
||||
return {
|
||||
name: path.basename(root),
|
||||
path: rel,
|
||||
targetExample,
|
||||
...resolveCandidateContextSummary(repoRoot, root, targetExample),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function resolveCandidateContextSummary(repoRoot, projectRoot, targetPath) {
|
||||
const ctx = resolveContext(repoRoot, { targetPath });
|
||||
return {
|
||||
productStatus: contextSourceStatus(ctx.productPath, repoRoot, projectRoot),
|
||||
productPath: contextSourcePath(ctx.productPath, repoRoot),
|
||||
designStatus: contextSourceStatus(ctx.designPath, repoRoot, projectRoot),
|
||||
designPath: contextSourcePath(ctx.designPath, repoRoot),
|
||||
};
|
||||
}
|
||||
|
||||
function contextSourceStatus(filePath, repoRoot, projectRoot) {
|
||||
if (!filePath) return 'missing';
|
||||
const absPath = path.resolve(filePath);
|
||||
const absProjectRoot = path.resolve(projectRoot);
|
||||
const absRepoRoot = path.resolve(repoRoot);
|
||||
if (isPathInsideOrEqual(absPath, absProjectRoot)) {
|
||||
return path.dirname(absPath) === absProjectRoot ? 'child' : 'fallback';
|
||||
}
|
||||
if (absProjectRoot !== absRepoRoot && isPathInsideOrEqual(absPath, absRepoRoot)) {
|
||||
return 'inherited';
|
||||
}
|
||||
return 'fallback';
|
||||
}
|
||||
|
||||
function contextSourcePath(filePath, repoRoot) {
|
||||
if (!filePath) return null;
|
||||
const rel = path.relative(repoRoot, filePath);
|
||||
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {
|
||||
return rel.split(path.sep).join('/');
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function discoverRootsForPattern(repoRoot, rawPattern) {
|
||||
const pattern = normalizeWorkspacePattern(rawPattern);
|
||||
if (!pattern || pattern.startsWith('!')) return [];
|
||||
const segments = pattern.split('/').filter(Boolean);
|
||||
if (!segments.length) return [];
|
||||
const firstGlobIndex = segments.findIndex((segment) => segment.includes('*'));
|
||||
const literalPrefix = firstGlobIndex === -1 ? segments : segments.slice(0, firstGlobIndex);
|
||||
const base = path.join(repoRoot, ...literalPrefix);
|
||||
if (!fs.existsSync(base)) return [];
|
||||
if (segments.includes('**')) {
|
||||
const packageRoots = [];
|
||||
walkDirs(base, (dir) => {
|
||||
if (dir !== base && isCandidateProjectRoot(dir)) packageRoots.push(dir);
|
||||
});
|
||||
if (packageRoots.length) return packageRoots;
|
||||
return directChildDirs(base);
|
||||
}
|
||||
return expandSimplePattern(repoRoot, segments);
|
||||
}
|
||||
|
||||
function expandSimplePattern(repoRoot, patternSegments, index = 0, current = repoRoot) {
|
||||
if (index >= patternSegments.length) return fs.existsSync(current) ? [current] : [];
|
||||
const segment = patternSegments[index];
|
||||
if (!segment.includes('*')) {
|
||||
return expandSimplePattern(repoRoot, patternSegments, index + 1, path.join(current, segment));
|
||||
}
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(current, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const roots = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
||||
if (!segmentMatches(segment, entry.name)) continue;
|
||||
roots.push(...expandSimplePattern(repoRoot, patternSegments, index + 1, path.join(current, entry.name)));
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
function directChildDirs(dir) {
|
||||
try {
|
||||
return fs.readdirSync(dir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() && !isIgnoredWorkspaceDiscoveryDir(entry.name))
|
||||
.map((entry) => path.join(dir, entry.name));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function walkDirs(root, visit) {
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(root, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
||||
const dir = path.join(root, entry.name);
|
||||
visit(dir);
|
||||
walkDirs(dir, visit);
|
||||
}
|
||||
}
|
||||
|
||||
function isCandidateProjectRoot(dir) {
|
||||
return !!(
|
||||
fs.existsSync(path.join(dir, 'package.json'))
|
||||
|| firstExisting(dir, [...PRODUCT_NAMES, ...DESIGN_NAMES])
|
||||
|| fs.existsSync(path.join(dir, 'src'))
|
||||
|| fs.existsSync(path.join(dir, 'app'))
|
||||
|| fs.existsSync(path.join(dir, 'pages'))
|
||||
|| fs.existsSync(path.join(dir, 'public'))
|
||||
);
|
||||
}
|
||||
|
||||
function isIgnoredWorkspaceDiscoveryDir(name) {
|
||||
return name.startsWith('.') || WORKSPACE_DISCOVERY_IGNORED_DIRS.has(name);
|
||||
}
|
||||
|
||||
function findTargetExample(repoRoot, projectRoot) {
|
||||
const examples = [
|
||||
'src/App.jsx',
|
||||
'src/App.tsx',
|
||||
'src/main.jsx',
|
||||
'src/main.tsx',
|
||||
'src/index.jsx',
|
||||
'src/index.ts',
|
||||
'app/page.tsx',
|
||||
'pages/index.tsx',
|
||||
'public/index.html',
|
||||
];
|
||||
for (const rel of examples) {
|
||||
const abs = path.join(projectRoot, rel);
|
||||
if (fs.existsSync(abs)) return path.relative(repoRoot, abs).split(path.sep).join('/');
|
||||
}
|
||||
return path.relative(repoRoot, projectRoot).split(path.sep).join('/');
|
||||
}
|
||||
|
||||
function resolveWorkspaceProjectRoot(repoRoot, targetDir) {
|
||||
const rel = path.relative(repoRoot, targetDir);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return repoRoot;
|
||||
const relSegments = rel.split(path.sep).filter(Boolean);
|
||||
const patterns = readWorkspacePatterns(repoRoot);
|
||||
const excluded = isExcludedByWorkspacePattern(relSegments, patterns);
|
||||
if (!excluded) {
|
||||
for (const pattern of patterns) {
|
||||
const projectRoot = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern);
|
||||
if (projectRoot) return projectRoot;
|
||||
}
|
||||
}
|
||||
if (excluded) return repoRoot;
|
||||
if (
|
||||
relSegments.length >= 2
|
||||
&& MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0])
|
||||
) {
|
||||
return path.join(repoRoot, relSegments[0], relSegments[1]);
|
||||
}
|
||||
const nearest = nearestProjectLikeRoot(repoRoot, targetDir);
|
||||
if (nearest) return nearest;
|
||||
return repoRoot;
|
||||
}
|
||||
|
||||
function isExcludedByWorkspacePattern(relSegments, patterns) {
|
||||
return patterns.some((rawPattern) => {
|
||||
const pattern = normalizeWorkspacePattern(rawPattern);
|
||||
if (!pattern.startsWith('!')) return false;
|
||||
return workspacePatternMatchesRel(pattern.slice(1), relSegments);
|
||||
});
|
||||
}
|
||||
|
||||
function nearestProjectLikeRoot(repoRoot, targetDir) {
|
||||
let dir = path.resolve(targetDir);
|
||||
const stop = path.resolve(repoRoot);
|
||||
while (dir && dir !== stop) {
|
||||
if (
|
||||
firstExisting(dir, [...PRODUCT_NAMES, ...DESIGN_NAMES])
|
||||
|| fs.existsSync(path.join(dir, 'package.json'))
|
||||
) {
|
||||
return dir;
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function nearestPackageRootBetween(repoRoot, targetDir, stopDir) {
|
||||
let dir = path.resolve(targetDir);
|
||||
const stop = path.resolve(stopDir || repoRoot);
|
||||
const root = path.resolve(repoRoot);
|
||||
while (dir && dir !== stop && isPathInsideOrEqual(dir, root)) {
|
||||
if (fs.existsSync(path.join(dir, 'package.json'))) return dir;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPathInsideOrEqual(candidate, root) {
|
||||
return path.resolve(candidate) === path.resolve(root) || isPathInside(candidate, root);
|
||||
}
|
||||
|
||||
function workspacePatternMatchesRel(pattern, relSegments) {
|
||||
const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return false;
|
||||
if (patternSegments.includes('**')) {
|
||||
const firstGlobIndex = patternSegments.findIndex((segment) => segment.includes('*'));
|
||||
const literalPrefix = firstGlobIndex === -1
|
||||
? patternSegments
|
||||
: patternSegments.slice(0, firstGlobIndex);
|
||||
if (relSegments.length < literalPrefix.length + 1) return false;
|
||||
for (let i = 0; i < literalPrefix.length; i++) {
|
||||
if (!segmentMatches(literalPrefix[i], relSegments[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (relSegments.length < patternSegments.length) return false;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function readWorkspacePatterns(repoRoot) {
|
||||
return [
|
||||
...readPackageWorkspaces(repoRoot),
|
||||
...readPnpmWorkspaces(repoRoot),
|
||||
...readLernaWorkspaces(repoRoot),
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
function readPackageWorkspaces(repoRoot) {
|
||||
const pkg = readJson(path.join(repoRoot, 'package.json'));
|
||||
const workspaces = pkg?.workspaces;
|
||||
if (Array.isArray(workspaces)) return workspaces;
|
||||
if (Array.isArray(workspaces?.packages)) return workspaces.packages;
|
||||
return [];
|
||||
}
|
||||
|
||||
function readLernaWorkspaces(repoRoot) {
|
||||
const lerna = readJson(path.join(repoRoot, 'lerna.json'));
|
||||
return Array.isArray(lerna?.packages) ? lerna.packages : [];
|
||||
}
|
||||
|
||||
function readPnpmWorkspaces(repoRoot) {
|
||||
try {
|
||||
const body = fs.readFileSync(path.join(repoRoot, 'pnpm-workspace.yaml'), 'utf-8');
|
||||
const patterns = [];
|
||||
let inPackages = false;
|
||||
for (const line of body.split(/\r?\n/)) {
|
||||
const trimmed = stripYamlInlineComment(line).trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const flowMatch = trimmed.match(/^packages:\s*\[(.*)\]\s*$/);
|
||||
if (flowMatch) {
|
||||
patterns.push(...parseYamlFlowList(flowMatch[1]));
|
||||
inPackages = false;
|
||||
continue;
|
||||
}
|
||||
if (/^packages:\s*$/.test(trimmed)) {
|
||||
inPackages = true;
|
||||
continue;
|
||||
}
|
||||
if (inPackages && /^[A-Za-z0-9_-]+:\s*/.test(trimmed)) break;
|
||||
if (inPackages) {
|
||||
const match = trimmed.match(/^-\s*(.+)$/);
|
||||
if (match) patterns.push(unquoteYamlValue(match[1]));
|
||||
}
|
||||
}
|
||||
return patterns;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function stripYamlInlineComment(line) {
|
||||
let quote = null;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
if ((ch === '"' || ch === "'") && line[i - 1] !== '\\') {
|
||||
quote = quote === ch ? null : quote || ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === '#' && !quote) return line.slice(0, i);
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
function parseYamlFlowList(body) {
|
||||
const items = [];
|
||||
let quote = null;
|
||||
let current = '';
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
const ch = body[i];
|
||||
if ((ch === '"' || ch === "'") && body[i - 1] !== '\\') {
|
||||
quote = quote === ch ? null : quote || ch;
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === ',' && !quote) {
|
||||
const value = unquoteYamlValue(current);
|
||||
if (value) items.push(value);
|
||||
current = '';
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
}
|
||||
const value = unquoteYamlValue(current);
|
||||
if (value) items.push(value);
|
||||
return items;
|
||||
}
|
||||
|
||||
function unquoteYamlValue(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '');
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function projectRootFromWorkspacePattern(repoRoot, relSegments, rawPattern) {
|
||||
const pattern = normalizeWorkspacePattern(rawPattern);
|
||||
if (!pattern || pattern.startsWith('!')) return null;
|
||||
const patternSegments = pattern.split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return null;
|
||||
if (patternSegments.includes('**')) {
|
||||
return projectRootFromDoubleStarPattern(repoRoot, relSegments, patternSegments);
|
||||
}
|
||||
if (relSegments.length < patternSegments.length) return null;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return null;
|
||||
}
|
||||
return path.join(repoRoot, ...relSegments.slice(0, patternSegments.length));
|
||||
}
|
||||
|
||||
function projectRootFromDoubleStarPattern(repoRoot, relSegments, patternSegments) {
|
||||
const firstGlobIndex = patternSegments.findIndex((segment) => segment.includes('*'));
|
||||
const literalPrefix = firstGlobIndex === -1
|
||||
? patternSegments
|
||||
: patternSegments.slice(0, firstGlobIndex);
|
||||
if (relSegments.length < literalPrefix.length + 1) return null;
|
||||
for (let i = 0; i < literalPrefix.length; i++) {
|
||||
if (!segmentMatches(literalPrefix[i], relSegments[i])) return null;
|
||||
}
|
||||
const prefixDir = path.join(repoRoot, ...literalPrefix);
|
||||
const targetDir = path.join(repoRoot, ...relSegments);
|
||||
const packageRoot = nearestPackageRootBetween(repoRoot, targetDir, prefixDir);
|
||||
if (packageRoot) return packageRoot;
|
||||
return path.join(repoRoot, ...relSegments.slice(0, literalPrefix.length + 1));
|
||||
}
|
||||
|
||||
function normalizeWorkspacePattern(pattern) {
|
||||
return String(pattern || '')
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '')
|
||||
.replace(/^\.\//, '')
|
||||
.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function segmentMatches(patternSegment, relSegment) {
|
||||
if (patternSegment === '*') return true;
|
||||
if (!patternSegment.includes('*')) return patternSegment === relSegment;
|
||||
const re = new RegExp(`^${escapeRegExp(patternSegment).replace(/\\\*/g, '[^/]*')}$`);
|
||||
return re.test(relSegment);
|
||||
}
|
||||
|
||||
function firstExisting(dir, names) {
|
||||
for (const name of names) {
|
||||
const abs = path.join(dir, name);
|
||||
@@ -89,6 +664,10 @@ function safeRead(p) {
|
||||
}
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the register (`brand` or `product`) out of PRODUCT.md by looking
|
||||
* for a `## Register` section and reading the first non-empty line that
|
||||
@@ -233,7 +812,24 @@ async function computeUpdateDirective(now = Date.now()) {
|
||||
}
|
||||
|
||||
async function cli() {
|
||||
const ctx = loadContext(process.cwd());
|
||||
let cliOptions;
|
||||
try {
|
||||
cliOptions = parseCliOptions(process.argv.slice(2));
|
||||
} catch (err) {
|
||||
if (err?.name === 'TargetArgError') {
|
||||
process.stderr.write(`${err.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const targetProvided = hasTargetOption(cliOptions);
|
||||
const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null;
|
||||
const selection = resolveTargetSelection(process.cwd(), cliOptions);
|
||||
if (selection) {
|
||||
process.stdout.write(buildTargetSelectionDirective(selection) + '\n');
|
||||
process.exit(0);
|
||||
}
|
||||
const ctx = loadContext(process.cwd(), cliOptions);
|
||||
const updateDirective = await computeUpdateDirective();
|
||||
|
||||
if (!ctx.hasProduct) {
|
||||
@@ -244,6 +840,10 @@ async function cli() {
|
||||
'Stop the current task, load reference/init.md, and follow its ' +
|
||||
'instructions to write PRODUCT.md before resuming.',
|
||||
];
|
||||
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
|
||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||
parts.push(buildMissingTargetDirective());
|
||||
}
|
||||
if (updateDirective) parts.push(updateDirective);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
process.exit(0);
|
||||
@@ -252,6 +852,10 @@ async function cli() {
|
||||
if (ctx.hasDesign) {
|
||||
parts.push(`# DESIGN.md\n\n${ctx.design.trim()}`);
|
||||
}
|
||||
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
|
||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||
parts.push(buildMissingTargetDirective());
|
||||
}
|
||||
const register = extractRegister(ctx.product);
|
||||
const next = register
|
||||
? `NEXT STEP: This project's register is \`${register}\`. You MUST now read \`reference/${register}.md\` before producing any design output.`
|
||||
@@ -261,6 +865,60 @@ async function cli() {
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
}
|
||||
|
||||
function parseCliOptions(args) {
|
||||
return parseTargetOptions(args, { strict: true });
|
||||
}
|
||||
|
||||
function hasTargetOption(options) {
|
||||
return !!(options && typeof options.targetPath === 'string' && options.targetPath.trim());
|
||||
}
|
||||
|
||||
function pathExistsForTarget(cwd, targetPath) {
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
return fs.existsSync(abs);
|
||||
}
|
||||
|
||||
function buildResolvedContextDirective(ctx, options, { targetExists = null } = {}) {
|
||||
const targetPath = hasTargetOption(options) ? options.targetPath : null;
|
||||
return `RESOLVED_CONTEXT:\n${JSON.stringify({
|
||||
targetPath,
|
||||
...(targetPath ? { targetExists } : {}),
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
productPath: ctx.productPath,
|
||||
designPath: ctx.designPath,
|
||||
}, null, 2)}`;
|
||||
}
|
||||
|
||||
function shouldWarnMissingTarget(ctx, targetProvided, targetExists = null) {
|
||||
if (ctx.isMonorepo && targetProvided && targetExists === false) return true;
|
||||
return !!(
|
||||
ctx.isMonorepo
|
||||
&& (!targetProvided || targetExists === false)
|
||||
&& ctx.projectRoot
|
||||
&& ctx.repoRoot
|
||||
&& path.resolve(ctx.projectRoot) === path.resolve(ctx.repoRoot)
|
||||
);
|
||||
}
|
||||
|
||||
function buildMissingTargetDirective() {
|
||||
const script = process.argv[1] || 'context.mjs';
|
||||
return (
|
||||
'MONOREPO_TARGET_REQUIRED: This is a monorepo and context.mjs ran without --target. ' +
|
||||
'If the user named a file, route, or child app, do not answer from this output. ' +
|
||||
`Rerun \`node ${script} --target <path>\` and answer from that run's RESOLVED_CONTEXT fields.`
|
||||
);
|
||||
}
|
||||
|
||||
function buildTargetSelectionDirective(selection) {
|
||||
return (
|
||||
`TARGET_SELECTION_REQUIRED:\n${JSON.stringify(selection, null, 2)}\n\n` +
|
||||
'Show each app with its productStatus/productPath and designStatus/designPath so the user can see child overrides, inherited root files, fallback files, or missing files before choosing. ' +
|
||||
'Ask the user which app Impeccable should use, then rerun Impeccable helper commands from that child app cwd using this same scripts directory. ' +
|
||||
'Use `--target <path>` only as a fallback when changing cwd is not possible, or when the user explicitly named a file/path.'
|
||||
);
|
||||
}
|
||||
|
||||
// Run cli() only when this module is the entry point. Compare realpaths
|
||||
// rather than endsWith(): a loose suffix match also fires for unrelated
|
||||
// scripts like `load-context.mjs`, and realpath tolerates symlinked
|
||||
|
||||
@@ -1,50 +1,52 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { resolveProjectRoot } from '../context.mjs';
|
||||
|
||||
export const IMPECCABLE_DIR = '.impeccable';
|
||||
export const LIVE_DIR = 'live';
|
||||
export const CRITIQUE_DIR = 'critique';
|
||||
|
||||
export function getImpeccableDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, IMPECCABLE_DIR);
|
||||
export function getImpeccableDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), IMPECCABLE_DIR);
|
||||
}
|
||||
|
||||
export function getDesignSidecarPath(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), 'design.json');
|
||||
export function getDesignSidecarPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), 'design.json');
|
||||
}
|
||||
|
||||
export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd) {
|
||||
export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd, options = {}) {
|
||||
const projectRoot = resolveProjectRoot(cwd, options);
|
||||
const candidates = [
|
||||
getDesignSidecarPath(cwd),
|
||||
path.join(cwd, 'DESIGN.json'),
|
||||
getDesignSidecarPath(cwd, options),
|
||||
path.join(projectRoot, 'DESIGN.json'),
|
||||
];
|
||||
const contextLegacy = path.join(contextDir, 'DESIGN.json');
|
||||
if (!candidates.includes(contextLegacy)) candidates.push(contextLegacy);
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) {
|
||||
return firstExisting(getDesignSidecarCandidates(cwd, contextDir));
|
||||
export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd, options = {}) {
|
||||
return firstExisting(getDesignSidecarCandidates(cwd, contextDir, options));
|
||||
}
|
||||
|
||||
export function getLiveDir(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), LIVE_DIR);
|
||||
export function getLiveDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), LIVE_DIR);
|
||||
}
|
||||
|
||||
export function getLiveConfigPath(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'config.json');
|
||||
export function getLiveConfigPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'config.json');
|
||||
}
|
||||
|
||||
export function getLegacyLiveConfigPath(scriptsDir) {
|
||||
return path.join(scriptsDir, 'config.json');
|
||||
}
|
||||
|
||||
export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env } = {}) {
|
||||
export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env, targetPath } = {}) {
|
||||
if (env.IMPECCABLE_LIVE_CONFIG && env.IMPECCABLE_LIVE_CONFIG.trim()) {
|
||||
const configured = env.IMPECCABLE_LIVE_CONFIG.trim();
|
||||
return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured);
|
||||
}
|
||||
const primary = getLiveConfigPath(cwd);
|
||||
const primary = getLiveConfigPath(cwd, { targetPath });
|
||||
if (fs.existsSync(primary)) return primary;
|
||||
if (scriptsDir) {
|
||||
const legacy = getLegacyLiveConfigPath(scriptsDir);
|
||||
@@ -53,16 +55,16 @@ export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = p
|
||||
return primary;
|
||||
}
|
||||
|
||||
export function getLiveServerPath(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'server.json');
|
||||
export function getLiveServerPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'server.json');
|
||||
}
|
||||
|
||||
export function getLegacyLiveServerPath(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live.json');
|
||||
export function getLegacyLiveServerPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live.json');
|
||||
}
|
||||
|
||||
export function readLiveServerInfo(cwd = process.cwd()) {
|
||||
for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) {
|
||||
export function readLiveServerInfo(cwd = process.cwd(), options = {}) {
|
||||
for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) {
|
||||
try {
|
||||
const info = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
if (info && typeof info.pid === 'number' && !isLiveServerPidReachable(info.pid)) {
|
||||
@@ -88,37 +90,37 @@ export function isLiveServerPidReachable(pid) {
|
||||
}
|
||||
}
|
||||
|
||||
export function writeLiveServerInfo(cwd = process.cwd(), info) {
|
||||
const filePath = getLiveServerPath(cwd);
|
||||
export function writeLiveServerInfo(cwd = process.cwd(), info, options = {}) {
|
||||
const filePath = getLiveServerPath(cwd, options);
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(info));
|
||||
return filePath;
|
||||
}
|
||||
|
||||
export function removeLiveServerInfo(cwd = process.cwd()) {
|
||||
for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) {
|
||||
export function removeLiveServerInfo(cwd = process.cwd(), options = {}) {
|
||||
for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) {
|
||||
try { fs.unlinkSync(filePath); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
export function getLiveSessionsDir(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'sessions');
|
||||
export function getLiveSessionsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'sessions');
|
||||
}
|
||||
|
||||
export function getLegacyLiveSessionsDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live', 'sessions');
|
||||
export function getLegacyLiveSessionsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'sessions');
|
||||
}
|
||||
|
||||
export function getLiveAnnotationsDir(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'annotations');
|
||||
export function getLiveAnnotationsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'annotations');
|
||||
}
|
||||
|
||||
export function getCritiqueDir(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), CRITIQUE_DIR);
|
||||
export function getCritiqueDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), CRITIQUE_DIR);
|
||||
}
|
||||
|
||||
export function getLegacyLiveAnnotationsDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live', 'annotations');
|
||||
export function getLegacyLiveAnnotationsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'annotations');
|
||||
}
|
||||
|
||||
function firstExisting(paths) {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
class TargetArgError extends Error {
|
||||
constructor(message, code) {
|
||||
super(message);
|
||||
this.name = 'TargetArgError';
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseTargetPath(args = [], { strict = false } = {}) {
|
||||
let targetPath = null;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = String(args[i]);
|
||||
if (arg === '--target' || arg === '-t') {
|
||||
const next = args[i + 1];
|
||||
if (next && !String(next).startsWith('-')) {
|
||||
targetPath = String(next);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (strict) {
|
||||
throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith('--target=')) {
|
||||
const value = arg.slice('--target='.length);
|
||||
if (value) {
|
||||
targetPath = value;
|
||||
continue;
|
||||
}
|
||||
if (strict) {
|
||||
throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING');
|
||||
}
|
||||
}
|
||||
}
|
||||
return targetPath;
|
||||
}
|
||||
|
||||
export function parseTargetOptions(args = [], options = {}) {
|
||||
const targetPath = parseTargetPath(args, options);
|
||||
return targetPath ? { targetPath } : {};
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import path from 'node:path';
|
||||
import net from 'node:net';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseDesignMd } from './lib/design-parser.mjs';
|
||||
import { resolveContextDir } from './context.mjs';
|
||||
import { loadContext } from './context.mjs';
|
||||
import {
|
||||
assembleLiveBrowserScript,
|
||||
assertLiveBrowserScriptParts,
|
||||
@@ -55,7 +55,11 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
// PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
|
||||
// DESIGN sidecar is project-local at .impeccable/design.json, with legacy
|
||||
// DESIGN.json fallback for existing projects.
|
||||
const CONTEXT_DIR = resolveContextDir(process.cwd());
|
||||
const PROJECT_CONTEXT = loadContext(process.cwd());
|
||||
const CONTEXT_DIR = PROJECT_CONTEXT.contextDir;
|
||||
const DESIGN_MD_PATH = PROJECT_CONTEXT.designPath
|
||||
? path.resolve(process.cwd(), PROJECT_CONTEXT.designPath)
|
||||
: null;
|
||||
const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway
|
||||
const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s
|
||||
|
||||
@@ -371,10 +375,7 @@ function hasProjectContext() {
|
||||
// PRODUCT.md carries brand voice / anti-references — that's what determines
|
||||
// whether variants are brand-aware. DESIGN.md (visual tokens) is a separate
|
||||
// concern, surfaced by the design panel's own empty state.
|
||||
try {
|
||||
fs.accessSync(path.join(CONTEXT_DIR, 'PRODUCT.md'), fs.constants.R_OK);
|
||||
return true;
|
||||
} catch { return false; }
|
||||
return !!PROJECT_CONTEXT.hasProduct;
|
||||
}
|
||||
|
||||
function statOrNull(filePath) {
|
||||
@@ -549,8 +550,8 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
const token = url.searchParams.get('token');
|
||||
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
|
||||
|
||||
const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md');
|
||||
const jsonPath = resolveDesignSidecarPath(process.cwd(), CONTEXT_DIR) || getDesignSidecarPath(process.cwd());
|
||||
const mdPath = DESIGN_MD_PATH;
|
||||
const jsonPath = resolveDesignSidecarPath(process.cwd(), PROJECT_CONTEXT.designContextDir || CONTEXT_DIR) || getDesignSidecarPath(process.cwd());
|
||||
const mdStat = statOrNull(mdPath);
|
||||
const jsonStat = statOrNull(jsonPath);
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import path from 'node:path';
|
||||
import { resolveProjectRoot } from './context.mjs';
|
||||
import { parseTargetPath } from './lib/target-args.mjs';
|
||||
|
||||
export function resolveLiveTarget(cwd = process.cwd(), args = []) {
|
||||
const originalCwd = path.resolve(cwd);
|
||||
let targetPath = null;
|
||||
try {
|
||||
targetPath = parseTargetPath(args, { strict: true });
|
||||
} catch (err) {
|
||||
if (err?.name === 'TargetArgError') {
|
||||
process.stderr.write(`${err.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const absoluteTargetPath = targetPath
|
||||
? path.isAbsolute(targetPath) ? targetPath : path.resolve(originalCwd, targetPath)
|
||||
: null;
|
||||
const projectRoot = targetPath
|
||||
? resolveProjectRoot(originalCwd, { targetPath: absoluteTargetPath })
|
||||
: originalCwd;
|
||||
return {
|
||||
originalCwd,
|
||||
projectRoot,
|
||||
targetPath,
|
||||
absoluteTargetPath,
|
||||
targetOptions: absoluteTargetPath ? { targetPath: absoluteTargetPath } : {},
|
||||
};
|
||||
}
|
||||
@@ -21,14 +21,16 @@ import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadContext } from './context.mjs';
|
||||
import { loadContext, resolveTargetSelection } from './context.mjs';
|
||||
import { resolveFiles } from './live-inject.mjs';
|
||||
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
|
||||
import { resolveLiveTarget } from './live-target.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
async function liveCli() {
|
||||
const args = process.argv.slice(2);
|
||||
const liveTarget = resolveLiveTarget(process.cwd(), args);
|
||||
|
||||
if (args.includes('--help') || args.includes('-h')) {
|
||||
console.log(`Usage: node live.mjs
|
||||
@@ -38,37 +40,78 @@ Prepare everything for live variant mode in a single command:
|
||||
- Starts (or reuses) the live server in the background
|
||||
- Injects the browser script tag
|
||||
- Reads PRODUCT.md / DESIGN.md for project context
|
||||
- In monorepos, choose a child app first; --target <path> is the fallback/manual path
|
||||
|
||||
On success, prints a JSON blob with:
|
||||
{ ok, serverPort, serverToken, pageFile, hasContext, context }
|
||||
{ ok, serverPort, serverToken, pageFiles, projectRoot, repoRoot, targetPath, productPath, designPath }
|
||||
|
||||
On target_selection_required, prints:
|
||||
{ ok: false, error: "target_selection_required", targetCandidates }
|
||||
|
||||
On config_missing, prints:
|
||||
{ ok: false, error: "config_missing", configPath, hint }
|
||||
|
||||
The agent should then:
|
||||
1. If config_missing, create the config and re-run this script
|
||||
2. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort)
|
||||
3. Enter the poll loop: node live-poll.mjs`);
|
||||
1. If target_selection_required, ask which app to use and rerun from that child cwd
|
||||
2. If config_missing, create the config and re-run this script
|
||||
3. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort)
|
||||
4. Enter the poll loop: node live-poll.mjs`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const targetSelection = resolveTargetSelection(liveTarget.originalCwd, liveTarget.targetOptions);
|
||||
if (targetSelection) {
|
||||
console.log(JSON.stringify({
|
||||
ok: false,
|
||||
error: 'target_selection_required',
|
||||
...targetSelection,
|
||||
hint: 'Ask the user which app Impeccable should use, then rerun live from that child app cwd. Use --target <path> only as a fallback or explicit path diagnostic.',
|
||||
}, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const ctx = loadContext(liveTarget.originalCwd, liveTarget.targetOptions);
|
||||
const activeCwd = ctx.projectRoot;
|
||||
const outputTargetPath = liveTarget.targetPath || null;
|
||||
|
||||
const missingContext = missingLiveContext(ctx);
|
||||
if (missingContext.length > 0) {
|
||||
console.log(JSON.stringify({
|
||||
ok: false,
|
||||
error: 'context_missing',
|
||||
missing: missingContext,
|
||||
nextCommand: missingContext.includes('PRODUCT.md') ? 'init' : 'document',
|
||||
targetPath: outputTargetPath,
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
productPath: ctx.productPath,
|
||||
designPath: ctx.designPath,
|
||||
}, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 1. Check config (fail fast if missing — no point starting anything else)
|
||||
const checkOut = runScript('live-inject.mjs', ['--check']);
|
||||
const checkOut = runScript('live-inject.mjs', ['--check'], { cwd: activeCwd });
|
||||
const checkResult = safeParse(checkOut);
|
||||
if (!checkResult || !checkResult.ok) {
|
||||
console.log(JSON.stringify(checkResult || { ok: false, error: 'check_failed', raw: checkOut }));
|
||||
console.log(JSON.stringify({
|
||||
...(checkResult || { ok: false, error: 'check_failed', raw: checkOut }),
|
||||
targetPath: outputTargetPath,
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
}));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 2. Start server (or reuse existing)
|
||||
const serverInfo = ensureServerRunning();
|
||||
const serverInfo = ensureServerRunning(activeCwd);
|
||||
if (!serverInfo) {
|
||||
console.log(JSON.stringify({ ok: false, error: 'server_start_failed' }));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 3. Inject the script tag at the current port
|
||||
const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)]);
|
||||
const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)], { cwd: activeCwd });
|
||||
const injectResult = safeParse(injectOut);
|
||||
if (!injectResult || !injectResult.ok) {
|
||||
console.log(JSON.stringify({
|
||||
@@ -80,22 +123,23 @@ The agent should then:
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 4. Load PRODUCT.md + DESIGN.md context.
|
||||
const ctx = loadContext(process.cwd());
|
||||
|
||||
// 5. Compute drift-heal: compare resolved inject targets against the
|
||||
// 4. 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);
|
||||
const resolvedFiles = resolveFiles(activeCwd, checkResult.config);
|
||||
const drift = scanForDrift(activeCwd, resolvedFiles, checkResult.config);
|
||||
|
||||
// 6. Emit everything the agent needs
|
||||
// 5. Emit everything the agent needs
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
serverPort: serverInfo.port,
|
||||
serverToken: serverInfo.token,
|
||||
pageFiles: resolvedFiles,
|
||||
liveConfigPath: checkResult.path,
|
||||
configDrift: drift,
|
||||
targetPath: outputTargetPath,
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
hasProduct: ctx.hasProduct,
|
||||
product: ctx.product,
|
||||
productPath: ctx.productPath,
|
||||
@@ -105,6 +149,13 @@ The agent should then:
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
function missingLiveContext(ctx) {
|
||||
const missing = [];
|
||||
if (!ctx.hasProduct) missing.push('PRODUCT.md');
|
||||
if (!ctx.hasDesign) missing.push('DESIGN.md');
|
||||
return missing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drift-heal scan. Walks the project for HTML files under common
|
||||
* page-source directories (public/, src/, app/, pages/) and reports any
|
||||
@@ -201,11 +252,11 @@ function globToRegex(pattern) {
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function runScript(name, args) {
|
||||
function runScript(name, args, options = {}) {
|
||||
const scriptPath = path.join(__dirname, name);
|
||||
const cmd = `node "${scriptPath}" ${args.map(a => `"${a}"`).join(' ')}`;
|
||||
try {
|
||||
return execSync(cmd, { encoding: 'utf-8', cwd: process.cwd(), timeout: 15_000 });
|
||||
return execSync(cmd, { encoding: 'utf-8', cwd: options.cwd || process.cwd(), timeout: 15_000 });
|
||||
} catch (err) {
|
||||
// execSync throws on non-zero exit; return stdout if any
|
||||
return err.stdout || err.message || '';
|
||||
@@ -219,10 +270,10 @@ function safeParse(out) {
|
||||
/**
|
||||
* Return { pid, port, token } for the running live server, starting one if needed.
|
||||
*/
|
||||
function ensureServerRunning() {
|
||||
function ensureServerRunning(cwd = process.cwd()) {
|
||||
// Try to reuse an existing server
|
||||
try {
|
||||
const existing = readLiveServerInfo(process.cwd())?.info;
|
||||
const existing = readLiveServerInfo(cwd)?.info;
|
||||
if (existing && existing.pid) {
|
||||
try {
|
||||
process.kill(existing.pid, 0); // throws if dead
|
||||
@@ -232,7 +283,7 @@ function ensureServerRunning() {
|
||||
} catch { /* no PID file */ }
|
||||
|
||||
// Start a new server
|
||||
const out = runScript('live-server.mjs', ['--background']);
|
||||
const out = runScript('live-server.mjs', ['--background'], { cwd });
|
||||
return safeParse(out);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ Designs and iterates production-grade frontend interfaces. Real working code, co
|
||||
|
||||
You MUST do these steps before proceeding:
|
||||
|
||||
1. Run `node .rovodev/skills/impeccable/scripts/context.mjs` once per session. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task.
|
||||
1. Run `node .rovodev/skills/impeccable/scripts/context.mjs` once per session. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .rovodev/skills/impeccable/scripts/context.mjs --target <path>` instead. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task.
|
||||
2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read `reference/<command>.md` next. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects.
|
||||
3. Familiarize yourself with any existing design system, conventions, and components in the code. Read at least one project file (CSS / tokens / theme / a representative component or page). **Required even when you've loaded a sub-command reference in step 2.** Don't reinvent the wheel; use what's there when it works, branch out when the UX wins.
|
||||
4. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md.
|
||||
|
||||
@@ -8,7 +8,7 @@ A running dev server with hot module replacement (Vite, Next.js, Bun, etc.), OR
|
||||
|
||||
Execute in order. No step skipped, no step reordered.
|
||||
|
||||
1. `live.mjs`: boot.
|
||||
1. `live.mjs`: boot. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .rovodev/skills/impeccable/scripts/live.mjs --target <path>` instead; then run the rest of this live session from the returned `projectRoot`.
|
||||
2. Open the app URL that serves `pageFile` (infer from `package.json`, docs, terminal output, or an open tab). Never use `serverPort`; it's the helper, not the app. **Cursor:** `browser_navigate` to that URL before polling; do not skip. **Other harnesses:** use the available browser tool; if the URL is uncertain, ask the user once.
|
||||
3. Poll loop with the default long timeout (600000 ms). After every event or `--reply`, run `live-poll.mjs` again immediately. Never pass a short `--timeout=`.
|
||||
|
||||
|
||||
@@ -5,11 +5,12 @@
|
||||
* init flow.
|
||||
*
|
||||
* Path resolution (first match wins):
|
||||
* 1. cwd, if PRODUCT.md or DESIGN.md is there
|
||||
* 2. .agents/context/ then docs/
|
||||
* 3. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user
|
||||
* 1. Active project root, if PRODUCT.md or DESIGN.md is there
|
||||
* 2. Active project .agents/context/ then docs/
|
||||
* 3. Monorepo root context, using the same order, as a per-file fallback
|
||||
* 4. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user
|
||||
* escape hatch, only consulted when defaults are empty
|
||||
* 4. cwd as a "nothing found" default
|
||||
* 5. Active project root as a "nothing found" default
|
||||
*
|
||||
* `resolveContextDir()` and `loadContext()` are also exported for the
|
||||
* server-side scripts (live.mjs, live-server.mjs) that need the structured
|
||||
@@ -19,10 +20,25 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseTargetOptions } from './lib/target-args.mjs';
|
||||
|
||||
const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md'];
|
||||
const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md'];
|
||||
const FALLBACK_DIRS = ['.agents/context', 'docs'];
|
||||
const MONOREPO_MARKER_FILES = ['pnpm-workspace.yaml', 'turbo.json', 'nx.json', 'lerna.json'];
|
||||
const MONOREPO_FALLBACK_PROJECT_DIRS = ['apps', 'packages'];
|
||||
const WORKSPACE_DISCOVERY_IGNORED_DIRS = new Set([
|
||||
'node_modules',
|
||||
'.git',
|
||||
'dist',
|
||||
'build',
|
||||
'.next',
|
||||
'.nuxt',
|
||||
'.svelte-kit',
|
||||
'.turbo',
|
||||
'.cache',
|
||||
'coverage',
|
||||
]);
|
||||
|
||||
// ─── Update check ──────────────────────────────────────────────────────────
|
||||
// Piggyback a lightweight skill-version check on the once-per-session boot.
|
||||
@@ -38,41 +54,600 @@ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // throttle the network poll to o
|
||||
const RENOTIFY_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000; // don't re-surface the same version for a week
|
||||
const FETCH_TIMEOUT_MS = 1200;
|
||||
|
||||
export function resolveContextDir(cwd = process.cwd()) {
|
||||
if (firstExisting(cwd, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return cwd;
|
||||
}
|
||||
for (const rel of FALLBACK_DIRS) {
|
||||
const candidate = path.resolve(cwd, rel);
|
||||
if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
const envDir = process.env.IMPECCABLE_CONTEXT_DIR;
|
||||
if (envDir && envDir.trim()) {
|
||||
const trimmed = envDir.trim();
|
||||
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
||||
}
|
||||
return cwd;
|
||||
export function resolveContextDir(cwd = process.cwd(), options = {}) {
|
||||
return resolveContext(cwd, options).contextDir;
|
||||
}
|
||||
|
||||
export function loadContext(cwd = process.cwd()) {
|
||||
const contextDir = resolveContextDir(cwd);
|
||||
const productPath = firstExisting(contextDir, PRODUCT_NAMES);
|
||||
const designPath = firstExisting(contextDir, DESIGN_NAMES);
|
||||
export function loadContext(cwd = process.cwd(), options = {}) {
|
||||
const resolved = resolveContext(cwd, options);
|
||||
const absCwd = path.resolve(cwd);
|
||||
const productPath = resolved.productPath;
|
||||
const designPath = resolved.designPath;
|
||||
const product = productPath ? safeRead(productPath) : null;
|
||||
const design = designPath ? safeRead(designPath) : null;
|
||||
return {
|
||||
hasProduct: !!product,
|
||||
product,
|
||||
productPath: productPath ? path.relative(cwd, productPath) : null,
|
||||
productPath: productPath ? path.relative(absCwd, productPath) : null,
|
||||
hasDesign: !!design,
|
||||
design,
|
||||
designPath: designPath ? path.relative(cwd, designPath) : null,
|
||||
contextDir,
|
||||
designPath: designPath ? path.relative(absCwd, designPath) : null,
|
||||
contextDir: resolved.contextDir,
|
||||
productContextDir: productPath ? path.dirname(productPath) : null,
|
||||
designContextDir: designPath ? path.dirname(designPath) : null,
|
||||
projectRoot: resolved.projectRoot,
|
||||
repoRoot: resolved.repoRoot,
|
||||
isMonorepo: resolved.isMonorepo,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveContext(cwd = process.cwd(), options = {}) {
|
||||
const absCwd = path.resolve(cwd);
|
||||
const project = resolveProject(absCwd, options);
|
||||
const projectContextDir = resolveLocalContextDir(project.projectRoot);
|
||||
const rootContextDir = project.isMonorepo && project.repoRoot !== project.projectRoot
|
||||
? resolveLocalContextDir(project.repoRoot)
|
||||
: null;
|
||||
|
||||
let productPath =
|
||||
(projectContextDir ? firstExisting(projectContextDir, PRODUCT_NAMES) : null)
|
||||
|| (rootContextDir ? firstExisting(rootContextDir, PRODUCT_NAMES) : null);
|
||||
let designPath =
|
||||
(projectContextDir ? firstExisting(projectContextDir, DESIGN_NAMES) : null)
|
||||
|| (rootContextDir ? firstExisting(rootContextDir, DESIGN_NAMES) : null);
|
||||
|
||||
let envContextDir = null;
|
||||
if (!productPath && !designPath) {
|
||||
envContextDir = resolveEnvContextDir(absCwd);
|
||||
if (envContextDir) {
|
||||
productPath = firstExisting(envContextDir, PRODUCT_NAMES);
|
||||
designPath = firstExisting(envContextDir, DESIGN_NAMES);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
contextDir: productPath
|
||||
? path.dirname(productPath)
|
||||
: designPath
|
||||
? path.dirname(designPath)
|
||||
: envContextDir || project.projectRoot,
|
||||
productPath,
|
||||
designPath,
|
||||
projectRoot: project.projectRoot,
|
||||
repoRoot: project.repoRoot,
|
||||
isMonorepo: project.isMonorepo,
|
||||
targetDir: project.targetDir,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveProjectRoot(cwd = process.cwd(), options = {}) {
|
||||
return resolveProject(cwd, options).projectRoot;
|
||||
}
|
||||
|
||||
export function resolveTargetSelection(cwd = process.cwd(), options = {}) {
|
||||
if (hasTargetOption(options)) return null;
|
||||
const project = resolveProject(cwd);
|
||||
if (
|
||||
!project.isMonorepo
|
||||
|| !project.projectRoot
|
||||
|| !project.repoRoot
|
||||
|| path.resolve(project.projectRoot) !== path.resolve(project.repoRoot)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
targetPath: null,
|
||||
projectRoot: project.projectRoot,
|
||||
repoRoot: project.repoRoot,
|
||||
targetCandidates: discoverTargetCandidates(project.repoRoot),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveProject(cwd = process.cwd(), options = {}) {
|
||||
const absCwd = path.resolve(cwd);
|
||||
const targetDir = resolveTargetDir(absCwd, options);
|
||||
let repoRoot = findMonorepoRoot(targetDir);
|
||||
if (!repoRoot && targetDir !== absCwd) {
|
||||
const cwdRepoRoot = findMonorepoRoot(absCwd);
|
||||
if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {
|
||||
repoRoot = cwdRepoRoot;
|
||||
}
|
||||
}
|
||||
if (!repoRoot) {
|
||||
return {
|
||||
targetDir,
|
||||
projectRoot: absCwd,
|
||||
repoRoot: absCwd,
|
||||
isMonorepo: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
targetDir,
|
||||
projectRoot: resolveWorkspaceProjectRoot(repoRoot, targetDir) || repoRoot,
|
||||
repoRoot,
|
||||
isMonorepo: true,
|
||||
};
|
||||
}
|
||||
|
||||
function isPathInside(candidate, root) {
|
||||
const rel = path.relative(root, candidate);
|
||||
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
|
||||
}
|
||||
|
||||
function resolveLocalContextDir(root) {
|
||||
if (firstExisting(root, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return root;
|
||||
}
|
||||
for (const rel of FALLBACK_DIRS) {
|
||||
const candidate = path.resolve(root, rel);
|
||||
if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveEnvContextDir(cwd) {
|
||||
const envDir = process.env.IMPECCABLE_CONTEXT_DIR;
|
||||
if (!envDir || !envDir.trim()) return null;
|
||||
const trimmed = envDir.trim();
|
||||
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
||||
}
|
||||
|
||||
function resolveTargetDir(cwd, options = {}) {
|
||||
const targetPath = options && typeof options === 'object' ? options.targetPath : null;
|
||||
if (!targetPath || !String(targetPath).trim()) return cwd;
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
try {
|
||||
const stat = fs.statSync(abs);
|
||||
return stat.isDirectory() ? abs : path.dirname(abs);
|
||||
} catch {
|
||||
return path.extname(abs) ? path.dirname(abs) : abs;
|
||||
}
|
||||
}
|
||||
|
||||
function findMonorepoRoot(startDir) {
|
||||
let dir = path.resolve(startDir);
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
while (true) {
|
||||
if (dir === homeDir) return null;
|
||||
if (isMonorepoRoot(dir)) return dir;
|
||||
if (hasGitBoundary(dir)) return null;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function isMonorepoRoot(dir) {
|
||||
if (readWorkspacePatterns(dir).some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) return true;
|
||||
if (!MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(dir, file)))) return false;
|
||||
return hasFallbackWorkspaceChildren(dir);
|
||||
}
|
||||
|
||||
function hasGitBoundary(dir) {
|
||||
return fs.existsSync(path.join(dir, '.git'));
|
||||
}
|
||||
|
||||
function hasFallbackWorkspaceChildren(dir) {
|
||||
for (const name of MONOREPO_FALLBACK_PROJECT_DIRS) {
|
||||
const base = path.join(dir, name);
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(base, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (entries.some((entry) => entry.isDirectory() && !isIgnoredWorkspaceDiscoveryDir(entry.name))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function discoverTargetCandidates(repoRoot) {
|
||||
const roots = new Map();
|
||||
for (const pattern of readWorkspacePatterns(repoRoot)) {
|
||||
for (const root of discoverRootsForPattern(repoRoot, pattern)) {
|
||||
roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root);
|
||||
}
|
||||
}
|
||||
if (MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(repoRoot, file)))) {
|
||||
for (const name of MONOREPO_FALLBACK_PROJECT_DIRS) {
|
||||
const base = path.join(repoRoot, name);
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(base, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
||||
const root = path.join(base, entry.name);
|
||||
roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...roots.entries()]
|
||||
.filter(([rel]) => rel && !rel.startsWith('..'))
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([rel, root]) => {
|
||||
const targetExample = findTargetExample(repoRoot, root);
|
||||
return {
|
||||
name: path.basename(root),
|
||||
path: rel,
|
||||
targetExample,
|
||||
...resolveCandidateContextSummary(repoRoot, root, targetExample),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function resolveCandidateContextSummary(repoRoot, projectRoot, targetPath) {
|
||||
const ctx = resolveContext(repoRoot, { targetPath });
|
||||
return {
|
||||
productStatus: contextSourceStatus(ctx.productPath, repoRoot, projectRoot),
|
||||
productPath: contextSourcePath(ctx.productPath, repoRoot),
|
||||
designStatus: contextSourceStatus(ctx.designPath, repoRoot, projectRoot),
|
||||
designPath: contextSourcePath(ctx.designPath, repoRoot),
|
||||
};
|
||||
}
|
||||
|
||||
function contextSourceStatus(filePath, repoRoot, projectRoot) {
|
||||
if (!filePath) return 'missing';
|
||||
const absPath = path.resolve(filePath);
|
||||
const absProjectRoot = path.resolve(projectRoot);
|
||||
const absRepoRoot = path.resolve(repoRoot);
|
||||
if (isPathInsideOrEqual(absPath, absProjectRoot)) {
|
||||
return path.dirname(absPath) === absProjectRoot ? 'child' : 'fallback';
|
||||
}
|
||||
if (absProjectRoot !== absRepoRoot && isPathInsideOrEqual(absPath, absRepoRoot)) {
|
||||
return 'inherited';
|
||||
}
|
||||
return 'fallback';
|
||||
}
|
||||
|
||||
function contextSourcePath(filePath, repoRoot) {
|
||||
if (!filePath) return null;
|
||||
const rel = path.relative(repoRoot, filePath);
|
||||
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {
|
||||
return rel.split(path.sep).join('/');
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function discoverRootsForPattern(repoRoot, rawPattern) {
|
||||
const pattern = normalizeWorkspacePattern(rawPattern);
|
||||
if (!pattern || pattern.startsWith('!')) return [];
|
||||
const segments = pattern.split('/').filter(Boolean);
|
||||
if (!segments.length) return [];
|
||||
const firstGlobIndex = segments.findIndex((segment) => segment.includes('*'));
|
||||
const literalPrefix = firstGlobIndex === -1 ? segments : segments.slice(0, firstGlobIndex);
|
||||
const base = path.join(repoRoot, ...literalPrefix);
|
||||
if (!fs.existsSync(base)) return [];
|
||||
if (segments.includes('**')) {
|
||||
const packageRoots = [];
|
||||
walkDirs(base, (dir) => {
|
||||
if (dir !== base && isCandidateProjectRoot(dir)) packageRoots.push(dir);
|
||||
});
|
||||
if (packageRoots.length) return packageRoots;
|
||||
return directChildDirs(base);
|
||||
}
|
||||
return expandSimplePattern(repoRoot, segments);
|
||||
}
|
||||
|
||||
function expandSimplePattern(repoRoot, patternSegments, index = 0, current = repoRoot) {
|
||||
if (index >= patternSegments.length) return fs.existsSync(current) ? [current] : [];
|
||||
const segment = patternSegments[index];
|
||||
if (!segment.includes('*')) {
|
||||
return expandSimplePattern(repoRoot, patternSegments, index + 1, path.join(current, segment));
|
||||
}
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(current, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const roots = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
||||
if (!segmentMatches(segment, entry.name)) continue;
|
||||
roots.push(...expandSimplePattern(repoRoot, patternSegments, index + 1, path.join(current, entry.name)));
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
function directChildDirs(dir) {
|
||||
try {
|
||||
return fs.readdirSync(dir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() && !isIgnoredWorkspaceDiscoveryDir(entry.name))
|
||||
.map((entry) => path.join(dir, entry.name));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function walkDirs(root, visit) {
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(root, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
||||
const dir = path.join(root, entry.name);
|
||||
visit(dir);
|
||||
walkDirs(dir, visit);
|
||||
}
|
||||
}
|
||||
|
||||
function isCandidateProjectRoot(dir) {
|
||||
return !!(
|
||||
fs.existsSync(path.join(dir, 'package.json'))
|
||||
|| firstExisting(dir, [...PRODUCT_NAMES, ...DESIGN_NAMES])
|
||||
|| fs.existsSync(path.join(dir, 'src'))
|
||||
|| fs.existsSync(path.join(dir, 'app'))
|
||||
|| fs.existsSync(path.join(dir, 'pages'))
|
||||
|| fs.existsSync(path.join(dir, 'public'))
|
||||
);
|
||||
}
|
||||
|
||||
function isIgnoredWorkspaceDiscoveryDir(name) {
|
||||
return name.startsWith('.') || WORKSPACE_DISCOVERY_IGNORED_DIRS.has(name);
|
||||
}
|
||||
|
||||
function findTargetExample(repoRoot, projectRoot) {
|
||||
const examples = [
|
||||
'src/App.jsx',
|
||||
'src/App.tsx',
|
||||
'src/main.jsx',
|
||||
'src/main.tsx',
|
||||
'src/index.jsx',
|
||||
'src/index.ts',
|
||||
'app/page.tsx',
|
||||
'pages/index.tsx',
|
||||
'public/index.html',
|
||||
];
|
||||
for (const rel of examples) {
|
||||
const abs = path.join(projectRoot, rel);
|
||||
if (fs.existsSync(abs)) return path.relative(repoRoot, abs).split(path.sep).join('/');
|
||||
}
|
||||
return path.relative(repoRoot, projectRoot).split(path.sep).join('/');
|
||||
}
|
||||
|
||||
function resolveWorkspaceProjectRoot(repoRoot, targetDir) {
|
||||
const rel = path.relative(repoRoot, targetDir);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return repoRoot;
|
||||
const relSegments = rel.split(path.sep).filter(Boolean);
|
||||
const patterns = readWorkspacePatterns(repoRoot);
|
||||
const excluded = isExcludedByWorkspacePattern(relSegments, patterns);
|
||||
if (!excluded) {
|
||||
for (const pattern of patterns) {
|
||||
const projectRoot = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern);
|
||||
if (projectRoot) return projectRoot;
|
||||
}
|
||||
}
|
||||
if (excluded) return repoRoot;
|
||||
if (
|
||||
relSegments.length >= 2
|
||||
&& MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0])
|
||||
) {
|
||||
return path.join(repoRoot, relSegments[0], relSegments[1]);
|
||||
}
|
||||
const nearest = nearestProjectLikeRoot(repoRoot, targetDir);
|
||||
if (nearest) return nearest;
|
||||
return repoRoot;
|
||||
}
|
||||
|
||||
function isExcludedByWorkspacePattern(relSegments, patterns) {
|
||||
return patterns.some((rawPattern) => {
|
||||
const pattern = normalizeWorkspacePattern(rawPattern);
|
||||
if (!pattern.startsWith('!')) return false;
|
||||
return workspacePatternMatchesRel(pattern.slice(1), relSegments);
|
||||
});
|
||||
}
|
||||
|
||||
function nearestProjectLikeRoot(repoRoot, targetDir) {
|
||||
let dir = path.resolve(targetDir);
|
||||
const stop = path.resolve(repoRoot);
|
||||
while (dir && dir !== stop) {
|
||||
if (
|
||||
firstExisting(dir, [...PRODUCT_NAMES, ...DESIGN_NAMES])
|
||||
|| fs.existsSync(path.join(dir, 'package.json'))
|
||||
) {
|
||||
return dir;
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function nearestPackageRootBetween(repoRoot, targetDir, stopDir) {
|
||||
let dir = path.resolve(targetDir);
|
||||
const stop = path.resolve(stopDir || repoRoot);
|
||||
const root = path.resolve(repoRoot);
|
||||
while (dir && dir !== stop && isPathInsideOrEqual(dir, root)) {
|
||||
if (fs.existsSync(path.join(dir, 'package.json'))) return dir;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPathInsideOrEqual(candidate, root) {
|
||||
return path.resolve(candidate) === path.resolve(root) || isPathInside(candidate, root);
|
||||
}
|
||||
|
||||
function workspacePatternMatchesRel(pattern, relSegments) {
|
||||
const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return false;
|
||||
if (patternSegments.includes('**')) {
|
||||
const firstGlobIndex = patternSegments.findIndex((segment) => segment.includes('*'));
|
||||
const literalPrefix = firstGlobIndex === -1
|
||||
? patternSegments
|
||||
: patternSegments.slice(0, firstGlobIndex);
|
||||
if (relSegments.length < literalPrefix.length + 1) return false;
|
||||
for (let i = 0; i < literalPrefix.length; i++) {
|
||||
if (!segmentMatches(literalPrefix[i], relSegments[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (relSegments.length < patternSegments.length) return false;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function readWorkspacePatterns(repoRoot) {
|
||||
return [
|
||||
...readPackageWorkspaces(repoRoot),
|
||||
...readPnpmWorkspaces(repoRoot),
|
||||
...readLernaWorkspaces(repoRoot),
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
function readPackageWorkspaces(repoRoot) {
|
||||
const pkg = readJson(path.join(repoRoot, 'package.json'));
|
||||
const workspaces = pkg?.workspaces;
|
||||
if (Array.isArray(workspaces)) return workspaces;
|
||||
if (Array.isArray(workspaces?.packages)) return workspaces.packages;
|
||||
return [];
|
||||
}
|
||||
|
||||
function readLernaWorkspaces(repoRoot) {
|
||||
const lerna = readJson(path.join(repoRoot, 'lerna.json'));
|
||||
return Array.isArray(lerna?.packages) ? lerna.packages : [];
|
||||
}
|
||||
|
||||
function readPnpmWorkspaces(repoRoot) {
|
||||
try {
|
||||
const body = fs.readFileSync(path.join(repoRoot, 'pnpm-workspace.yaml'), 'utf-8');
|
||||
const patterns = [];
|
||||
let inPackages = false;
|
||||
for (const line of body.split(/\r?\n/)) {
|
||||
const trimmed = stripYamlInlineComment(line).trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const flowMatch = trimmed.match(/^packages:\s*\[(.*)\]\s*$/);
|
||||
if (flowMatch) {
|
||||
patterns.push(...parseYamlFlowList(flowMatch[1]));
|
||||
inPackages = false;
|
||||
continue;
|
||||
}
|
||||
if (/^packages:\s*$/.test(trimmed)) {
|
||||
inPackages = true;
|
||||
continue;
|
||||
}
|
||||
if (inPackages && /^[A-Za-z0-9_-]+:\s*/.test(trimmed)) break;
|
||||
if (inPackages) {
|
||||
const match = trimmed.match(/^-\s*(.+)$/);
|
||||
if (match) patterns.push(unquoteYamlValue(match[1]));
|
||||
}
|
||||
}
|
||||
return patterns;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function stripYamlInlineComment(line) {
|
||||
let quote = null;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
if ((ch === '"' || ch === "'") && line[i - 1] !== '\\') {
|
||||
quote = quote === ch ? null : quote || ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === '#' && !quote) return line.slice(0, i);
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
function parseYamlFlowList(body) {
|
||||
const items = [];
|
||||
let quote = null;
|
||||
let current = '';
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
const ch = body[i];
|
||||
if ((ch === '"' || ch === "'") && body[i - 1] !== '\\') {
|
||||
quote = quote === ch ? null : quote || ch;
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === ',' && !quote) {
|
||||
const value = unquoteYamlValue(current);
|
||||
if (value) items.push(value);
|
||||
current = '';
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
}
|
||||
const value = unquoteYamlValue(current);
|
||||
if (value) items.push(value);
|
||||
return items;
|
||||
}
|
||||
|
||||
function unquoteYamlValue(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '');
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function projectRootFromWorkspacePattern(repoRoot, relSegments, rawPattern) {
|
||||
const pattern = normalizeWorkspacePattern(rawPattern);
|
||||
if (!pattern || pattern.startsWith('!')) return null;
|
||||
const patternSegments = pattern.split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return null;
|
||||
if (patternSegments.includes('**')) {
|
||||
return projectRootFromDoubleStarPattern(repoRoot, relSegments, patternSegments);
|
||||
}
|
||||
if (relSegments.length < patternSegments.length) return null;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return null;
|
||||
}
|
||||
return path.join(repoRoot, ...relSegments.slice(0, patternSegments.length));
|
||||
}
|
||||
|
||||
function projectRootFromDoubleStarPattern(repoRoot, relSegments, patternSegments) {
|
||||
const firstGlobIndex = patternSegments.findIndex((segment) => segment.includes('*'));
|
||||
const literalPrefix = firstGlobIndex === -1
|
||||
? patternSegments
|
||||
: patternSegments.slice(0, firstGlobIndex);
|
||||
if (relSegments.length < literalPrefix.length + 1) return null;
|
||||
for (let i = 0; i < literalPrefix.length; i++) {
|
||||
if (!segmentMatches(literalPrefix[i], relSegments[i])) return null;
|
||||
}
|
||||
const prefixDir = path.join(repoRoot, ...literalPrefix);
|
||||
const targetDir = path.join(repoRoot, ...relSegments);
|
||||
const packageRoot = nearestPackageRootBetween(repoRoot, targetDir, prefixDir);
|
||||
if (packageRoot) return packageRoot;
|
||||
return path.join(repoRoot, ...relSegments.slice(0, literalPrefix.length + 1));
|
||||
}
|
||||
|
||||
function normalizeWorkspacePattern(pattern) {
|
||||
return String(pattern || '')
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '')
|
||||
.replace(/^\.\//, '')
|
||||
.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function segmentMatches(patternSegment, relSegment) {
|
||||
if (patternSegment === '*') return true;
|
||||
if (!patternSegment.includes('*')) return patternSegment === relSegment;
|
||||
const re = new RegExp(`^${escapeRegExp(patternSegment).replace(/\\\*/g, '[^/]*')}$`);
|
||||
return re.test(relSegment);
|
||||
}
|
||||
|
||||
function firstExisting(dir, names) {
|
||||
for (const name of names) {
|
||||
const abs = path.join(dir, name);
|
||||
@@ -89,6 +664,10 @@ function safeRead(p) {
|
||||
}
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the register (`brand` or `product`) out of PRODUCT.md by looking
|
||||
* for a `## Register` section and reading the first non-empty line that
|
||||
@@ -233,7 +812,24 @@ async function computeUpdateDirective(now = Date.now()) {
|
||||
}
|
||||
|
||||
async function cli() {
|
||||
const ctx = loadContext(process.cwd());
|
||||
let cliOptions;
|
||||
try {
|
||||
cliOptions = parseCliOptions(process.argv.slice(2));
|
||||
} catch (err) {
|
||||
if (err?.name === 'TargetArgError') {
|
||||
process.stderr.write(`${err.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const targetProvided = hasTargetOption(cliOptions);
|
||||
const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null;
|
||||
const selection = resolveTargetSelection(process.cwd(), cliOptions);
|
||||
if (selection) {
|
||||
process.stdout.write(buildTargetSelectionDirective(selection) + '\n');
|
||||
process.exit(0);
|
||||
}
|
||||
const ctx = loadContext(process.cwd(), cliOptions);
|
||||
const updateDirective = await computeUpdateDirective();
|
||||
|
||||
if (!ctx.hasProduct) {
|
||||
@@ -244,6 +840,10 @@ async function cli() {
|
||||
'Stop the current task, load reference/init.md, and follow its ' +
|
||||
'instructions to write PRODUCT.md before resuming.',
|
||||
];
|
||||
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
|
||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||
parts.push(buildMissingTargetDirective());
|
||||
}
|
||||
if (updateDirective) parts.push(updateDirective);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
process.exit(0);
|
||||
@@ -252,6 +852,10 @@ async function cli() {
|
||||
if (ctx.hasDesign) {
|
||||
parts.push(`# DESIGN.md\n\n${ctx.design.trim()}`);
|
||||
}
|
||||
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
|
||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||
parts.push(buildMissingTargetDirective());
|
||||
}
|
||||
const register = extractRegister(ctx.product);
|
||||
const next = register
|
||||
? `NEXT STEP: This project's register is \`${register}\`. You MUST now read \`reference/${register}.md\` before producing any design output.`
|
||||
@@ -261,6 +865,60 @@ async function cli() {
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
}
|
||||
|
||||
function parseCliOptions(args) {
|
||||
return parseTargetOptions(args, { strict: true });
|
||||
}
|
||||
|
||||
function hasTargetOption(options) {
|
||||
return !!(options && typeof options.targetPath === 'string' && options.targetPath.trim());
|
||||
}
|
||||
|
||||
function pathExistsForTarget(cwd, targetPath) {
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
return fs.existsSync(abs);
|
||||
}
|
||||
|
||||
function buildResolvedContextDirective(ctx, options, { targetExists = null } = {}) {
|
||||
const targetPath = hasTargetOption(options) ? options.targetPath : null;
|
||||
return `RESOLVED_CONTEXT:\n${JSON.stringify({
|
||||
targetPath,
|
||||
...(targetPath ? { targetExists } : {}),
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
productPath: ctx.productPath,
|
||||
designPath: ctx.designPath,
|
||||
}, null, 2)}`;
|
||||
}
|
||||
|
||||
function shouldWarnMissingTarget(ctx, targetProvided, targetExists = null) {
|
||||
if (ctx.isMonorepo && targetProvided && targetExists === false) return true;
|
||||
return !!(
|
||||
ctx.isMonorepo
|
||||
&& (!targetProvided || targetExists === false)
|
||||
&& ctx.projectRoot
|
||||
&& ctx.repoRoot
|
||||
&& path.resolve(ctx.projectRoot) === path.resolve(ctx.repoRoot)
|
||||
);
|
||||
}
|
||||
|
||||
function buildMissingTargetDirective() {
|
||||
const script = process.argv[1] || 'context.mjs';
|
||||
return (
|
||||
'MONOREPO_TARGET_REQUIRED: This is a monorepo and context.mjs ran without --target. ' +
|
||||
'If the user named a file, route, or child app, do not answer from this output. ' +
|
||||
`Rerun \`node ${script} --target <path>\` and answer from that run's RESOLVED_CONTEXT fields.`
|
||||
);
|
||||
}
|
||||
|
||||
function buildTargetSelectionDirective(selection) {
|
||||
return (
|
||||
`TARGET_SELECTION_REQUIRED:\n${JSON.stringify(selection, null, 2)}\n\n` +
|
||||
'Show each app with its productStatus/productPath and designStatus/designPath so the user can see child overrides, inherited root files, fallback files, or missing files before choosing. ' +
|
||||
'Ask the user which app Impeccable should use, then rerun Impeccable helper commands from that child app cwd using this same scripts directory. ' +
|
||||
'Use `--target <path>` only as a fallback when changing cwd is not possible, or when the user explicitly named a file/path.'
|
||||
);
|
||||
}
|
||||
|
||||
// Run cli() only when this module is the entry point. Compare realpaths
|
||||
// rather than endsWith(): a loose suffix match also fires for unrelated
|
||||
// scripts like `load-context.mjs`, and realpath tolerates symlinked
|
||||
|
||||
@@ -1,50 +1,52 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { resolveProjectRoot } from '../context.mjs';
|
||||
|
||||
export const IMPECCABLE_DIR = '.impeccable';
|
||||
export const LIVE_DIR = 'live';
|
||||
export const CRITIQUE_DIR = 'critique';
|
||||
|
||||
export function getImpeccableDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, IMPECCABLE_DIR);
|
||||
export function getImpeccableDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), IMPECCABLE_DIR);
|
||||
}
|
||||
|
||||
export function getDesignSidecarPath(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), 'design.json');
|
||||
export function getDesignSidecarPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), 'design.json');
|
||||
}
|
||||
|
||||
export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd) {
|
||||
export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd, options = {}) {
|
||||
const projectRoot = resolveProjectRoot(cwd, options);
|
||||
const candidates = [
|
||||
getDesignSidecarPath(cwd),
|
||||
path.join(cwd, 'DESIGN.json'),
|
||||
getDesignSidecarPath(cwd, options),
|
||||
path.join(projectRoot, 'DESIGN.json'),
|
||||
];
|
||||
const contextLegacy = path.join(contextDir, 'DESIGN.json');
|
||||
if (!candidates.includes(contextLegacy)) candidates.push(contextLegacy);
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) {
|
||||
return firstExisting(getDesignSidecarCandidates(cwd, contextDir));
|
||||
export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd, options = {}) {
|
||||
return firstExisting(getDesignSidecarCandidates(cwd, contextDir, options));
|
||||
}
|
||||
|
||||
export function getLiveDir(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), LIVE_DIR);
|
||||
export function getLiveDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), LIVE_DIR);
|
||||
}
|
||||
|
||||
export function getLiveConfigPath(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'config.json');
|
||||
export function getLiveConfigPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'config.json');
|
||||
}
|
||||
|
||||
export function getLegacyLiveConfigPath(scriptsDir) {
|
||||
return path.join(scriptsDir, 'config.json');
|
||||
}
|
||||
|
||||
export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env } = {}) {
|
||||
export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env, targetPath } = {}) {
|
||||
if (env.IMPECCABLE_LIVE_CONFIG && env.IMPECCABLE_LIVE_CONFIG.trim()) {
|
||||
const configured = env.IMPECCABLE_LIVE_CONFIG.trim();
|
||||
return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured);
|
||||
}
|
||||
const primary = getLiveConfigPath(cwd);
|
||||
const primary = getLiveConfigPath(cwd, { targetPath });
|
||||
if (fs.existsSync(primary)) return primary;
|
||||
if (scriptsDir) {
|
||||
const legacy = getLegacyLiveConfigPath(scriptsDir);
|
||||
@@ -53,16 +55,16 @@ export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = p
|
||||
return primary;
|
||||
}
|
||||
|
||||
export function getLiveServerPath(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'server.json');
|
||||
export function getLiveServerPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'server.json');
|
||||
}
|
||||
|
||||
export function getLegacyLiveServerPath(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live.json');
|
||||
export function getLegacyLiveServerPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live.json');
|
||||
}
|
||||
|
||||
export function readLiveServerInfo(cwd = process.cwd()) {
|
||||
for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) {
|
||||
export function readLiveServerInfo(cwd = process.cwd(), options = {}) {
|
||||
for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) {
|
||||
try {
|
||||
const info = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
if (info && typeof info.pid === 'number' && !isLiveServerPidReachable(info.pid)) {
|
||||
@@ -88,37 +90,37 @@ export function isLiveServerPidReachable(pid) {
|
||||
}
|
||||
}
|
||||
|
||||
export function writeLiveServerInfo(cwd = process.cwd(), info) {
|
||||
const filePath = getLiveServerPath(cwd);
|
||||
export function writeLiveServerInfo(cwd = process.cwd(), info, options = {}) {
|
||||
const filePath = getLiveServerPath(cwd, options);
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(info));
|
||||
return filePath;
|
||||
}
|
||||
|
||||
export function removeLiveServerInfo(cwd = process.cwd()) {
|
||||
for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) {
|
||||
export function removeLiveServerInfo(cwd = process.cwd(), options = {}) {
|
||||
for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) {
|
||||
try { fs.unlinkSync(filePath); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
export function getLiveSessionsDir(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'sessions');
|
||||
export function getLiveSessionsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'sessions');
|
||||
}
|
||||
|
||||
export function getLegacyLiveSessionsDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live', 'sessions');
|
||||
export function getLegacyLiveSessionsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'sessions');
|
||||
}
|
||||
|
||||
export function getLiveAnnotationsDir(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'annotations');
|
||||
export function getLiveAnnotationsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'annotations');
|
||||
}
|
||||
|
||||
export function getCritiqueDir(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), CRITIQUE_DIR);
|
||||
export function getCritiqueDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), CRITIQUE_DIR);
|
||||
}
|
||||
|
||||
export function getLegacyLiveAnnotationsDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live', 'annotations');
|
||||
export function getLegacyLiveAnnotationsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'annotations');
|
||||
}
|
||||
|
||||
function firstExisting(paths) {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
class TargetArgError extends Error {
|
||||
constructor(message, code) {
|
||||
super(message);
|
||||
this.name = 'TargetArgError';
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseTargetPath(args = [], { strict = false } = {}) {
|
||||
let targetPath = null;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = String(args[i]);
|
||||
if (arg === '--target' || arg === '-t') {
|
||||
const next = args[i + 1];
|
||||
if (next && !String(next).startsWith('-')) {
|
||||
targetPath = String(next);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (strict) {
|
||||
throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith('--target=')) {
|
||||
const value = arg.slice('--target='.length);
|
||||
if (value) {
|
||||
targetPath = value;
|
||||
continue;
|
||||
}
|
||||
if (strict) {
|
||||
throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING');
|
||||
}
|
||||
}
|
||||
}
|
||||
return targetPath;
|
||||
}
|
||||
|
||||
export function parseTargetOptions(args = [], options = {}) {
|
||||
const targetPath = parseTargetPath(args, options);
|
||||
return targetPath ? { targetPath } : {};
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import path from 'node:path';
|
||||
import net from 'node:net';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseDesignMd } from './lib/design-parser.mjs';
|
||||
import { resolveContextDir } from './context.mjs';
|
||||
import { loadContext } from './context.mjs';
|
||||
import {
|
||||
assembleLiveBrowserScript,
|
||||
assertLiveBrowserScriptParts,
|
||||
@@ -55,7 +55,11 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
// PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
|
||||
// DESIGN sidecar is project-local at .impeccable/design.json, with legacy
|
||||
// DESIGN.json fallback for existing projects.
|
||||
const CONTEXT_DIR = resolveContextDir(process.cwd());
|
||||
const PROJECT_CONTEXT = loadContext(process.cwd());
|
||||
const CONTEXT_DIR = PROJECT_CONTEXT.contextDir;
|
||||
const DESIGN_MD_PATH = PROJECT_CONTEXT.designPath
|
||||
? path.resolve(process.cwd(), PROJECT_CONTEXT.designPath)
|
||||
: null;
|
||||
const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway
|
||||
const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s
|
||||
|
||||
@@ -371,10 +375,7 @@ function hasProjectContext() {
|
||||
// PRODUCT.md carries brand voice / anti-references — that's what determines
|
||||
// whether variants are brand-aware. DESIGN.md (visual tokens) is a separate
|
||||
// concern, surfaced by the design panel's own empty state.
|
||||
try {
|
||||
fs.accessSync(path.join(CONTEXT_DIR, 'PRODUCT.md'), fs.constants.R_OK);
|
||||
return true;
|
||||
} catch { return false; }
|
||||
return !!PROJECT_CONTEXT.hasProduct;
|
||||
}
|
||||
|
||||
function statOrNull(filePath) {
|
||||
@@ -549,8 +550,8 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
const token = url.searchParams.get('token');
|
||||
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
|
||||
|
||||
const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md');
|
||||
const jsonPath = resolveDesignSidecarPath(process.cwd(), CONTEXT_DIR) || getDesignSidecarPath(process.cwd());
|
||||
const mdPath = DESIGN_MD_PATH;
|
||||
const jsonPath = resolveDesignSidecarPath(process.cwd(), PROJECT_CONTEXT.designContextDir || CONTEXT_DIR) || getDesignSidecarPath(process.cwd());
|
||||
const mdStat = statOrNull(mdPath);
|
||||
const jsonStat = statOrNull(jsonPath);
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import path from 'node:path';
|
||||
import { resolveProjectRoot } from './context.mjs';
|
||||
import { parseTargetPath } from './lib/target-args.mjs';
|
||||
|
||||
export function resolveLiveTarget(cwd = process.cwd(), args = []) {
|
||||
const originalCwd = path.resolve(cwd);
|
||||
let targetPath = null;
|
||||
try {
|
||||
targetPath = parseTargetPath(args, { strict: true });
|
||||
} catch (err) {
|
||||
if (err?.name === 'TargetArgError') {
|
||||
process.stderr.write(`${err.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const absoluteTargetPath = targetPath
|
||||
? path.isAbsolute(targetPath) ? targetPath : path.resolve(originalCwd, targetPath)
|
||||
: null;
|
||||
const projectRoot = targetPath
|
||||
? resolveProjectRoot(originalCwd, { targetPath: absoluteTargetPath })
|
||||
: originalCwd;
|
||||
return {
|
||||
originalCwd,
|
||||
projectRoot,
|
||||
targetPath,
|
||||
absoluteTargetPath,
|
||||
targetOptions: absoluteTargetPath ? { targetPath: absoluteTargetPath } : {},
|
||||
};
|
||||
}
|
||||
@@ -21,14 +21,16 @@ import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadContext } from './context.mjs';
|
||||
import { loadContext, resolveTargetSelection } from './context.mjs';
|
||||
import { resolveFiles } from './live-inject.mjs';
|
||||
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
|
||||
import { resolveLiveTarget } from './live-target.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
async function liveCli() {
|
||||
const args = process.argv.slice(2);
|
||||
const liveTarget = resolveLiveTarget(process.cwd(), args);
|
||||
|
||||
if (args.includes('--help') || args.includes('-h')) {
|
||||
console.log(`Usage: node live.mjs
|
||||
@@ -38,37 +40,78 @@ Prepare everything for live variant mode in a single command:
|
||||
- Starts (or reuses) the live server in the background
|
||||
- Injects the browser script tag
|
||||
- Reads PRODUCT.md / DESIGN.md for project context
|
||||
- In monorepos, choose a child app first; --target <path> is the fallback/manual path
|
||||
|
||||
On success, prints a JSON blob with:
|
||||
{ ok, serverPort, serverToken, pageFile, hasContext, context }
|
||||
{ ok, serverPort, serverToken, pageFiles, projectRoot, repoRoot, targetPath, productPath, designPath }
|
||||
|
||||
On target_selection_required, prints:
|
||||
{ ok: false, error: "target_selection_required", targetCandidates }
|
||||
|
||||
On config_missing, prints:
|
||||
{ ok: false, error: "config_missing", configPath, hint }
|
||||
|
||||
The agent should then:
|
||||
1. If config_missing, create the config and re-run this script
|
||||
2. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort)
|
||||
3. Enter the poll loop: node live-poll.mjs`);
|
||||
1. If target_selection_required, ask which app to use and rerun from that child cwd
|
||||
2. If config_missing, create the config and re-run this script
|
||||
3. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort)
|
||||
4. Enter the poll loop: node live-poll.mjs`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const targetSelection = resolveTargetSelection(liveTarget.originalCwd, liveTarget.targetOptions);
|
||||
if (targetSelection) {
|
||||
console.log(JSON.stringify({
|
||||
ok: false,
|
||||
error: 'target_selection_required',
|
||||
...targetSelection,
|
||||
hint: 'Ask the user which app Impeccable should use, then rerun live from that child app cwd. Use --target <path> only as a fallback or explicit path diagnostic.',
|
||||
}, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const ctx = loadContext(liveTarget.originalCwd, liveTarget.targetOptions);
|
||||
const activeCwd = ctx.projectRoot;
|
||||
const outputTargetPath = liveTarget.targetPath || null;
|
||||
|
||||
const missingContext = missingLiveContext(ctx);
|
||||
if (missingContext.length > 0) {
|
||||
console.log(JSON.stringify({
|
||||
ok: false,
|
||||
error: 'context_missing',
|
||||
missing: missingContext,
|
||||
nextCommand: missingContext.includes('PRODUCT.md') ? 'init' : 'document',
|
||||
targetPath: outputTargetPath,
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
productPath: ctx.productPath,
|
||||
designPath: ctx.designPath,
|
||||
}, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 1. Check config (fail fast if missing — no point starting anything else)
|
||||
const checkOut = runScript('live-inject.mjs', ['--check']);
|
||||
const checkOut = runScript('live-inject.mjs', ['--check'], { cwd: activeCwd });
|
||||
const checkResult = safeParse(checkOut);
|
||||
if (!checkResult || !checkResult.ok) {
|
||||
console.log(JSON.stringify(checkResult || { ok: false, error: 'check_failed', raw: checkOut }));
|
||||
console.log(JSON.stringify({
|
||||
...(checkResult || { ok: false, error: 'check_failed', raw: checkOut }),
|
||||
targetPath: outputTargetPath,
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
}));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 2. Start server (or reuse existing)
|
||||
const serverInfo = ensureServerRunning();
|
||||
const serverInfo = ensureServerRunning(activeCwd);
|
||||
if (!serverInfo) {
|
||||
console.log(JSON.stringify({ ok: false, error: 'server_start_failed' }));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 3. Inject the script tag at the current port
|
||||
const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)]);
|
||||
const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)], { cwd: activeCwd });
|
||||
const injectResult = safeParse(injectOut);
|
||||
if (!injectResult || !injectResult.ok) {
|
||||
console.log(JSON.stringify({
|
||||
@@ -80,22 +123,23 @@ The agent should then:
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 4. Load PRODUCT.md + DESIGN.md context.
|
||||
const ctx = loadContext(process.cwd());
|
||||
|
||||
// 5. Compute drift-heal: compare resolved inject targets against the
|
||||
// 4. 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);
|
||||
const resolvedFiles = resolveFiles(activeCwd, checkResult.config);
|
||||
const drift = scanForDrift(activeCwd, resolvedFiles, checkResult.config);
|
||||
|
||||
// 6. Emit everything the agent needs
|
||||
// 5. Emit everything the agent needs
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
serverPort: serverInfo.port,
|
||||
serverToken: serverInfo.token,
|
||||
pageFiles: resolvedFiles,
|
||||
liveConfigPath: checkResult.path,
|
||||
configDrift: drift,
|
||||
targetPath: outputTargetPath,
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
hasProduct: ctx.hasProduct,
|
||||
product: ctx.product,
|
||||
productPath: ctx.productPath,
|
||||
@@ -105,6 +149,13 @@ The agent should then:
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
function missingLiveContext(ctx) {
|
||||
const missing = [];
|
||||
if (!ctx.hasProduct) missing.push('PRODUCT.md');
|
||||
if (!ctx.hasDesign) missing.push('DESIGN.md');
|
||||
return missing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drift-heal scan. Walks the project for HTML files under common
|
||||
* page-source directories (public/, src/, app/, pages/) and reports any
|
||||
@@ -201,11 +252,11 @@ function globToRegex(pattern) {
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function runScript(name, args) {
|
||||
function runScript(name, args, options = {}) {
|
||||
const scriptPath = path.join(__dirname, name);
|
||||
const cmd = `node "${scriptPath}" ${args.map(a => `"${a}"`).join(' ')}`;
|
||||
try {
|
||||
return execSync(cmd, { encoding: 'utf-8', cwd: process.cwd(), timeout: 15_000 });
|
||||
return execSync(cmd, { encoding: 'utf-8', cwd: options.cwd || process.cwd(), timeout: 15_000 });
|
||||
} catch (err) {
|
||||
// execSync throws on non-zero exit; return stdout if any
|
||||
return err.stdout || err.message || '';
|
||||
@@ -219,10 +270,10 @@ function safeParse(out) {
|
||||
/**
|
||||
* Return { pid, port, token } for the running live server, starting one if needed.
|
||||
*/
|
||||
function ensureServerRunning() {
|
||||
function ensureServerRunning(cwd = process.cwd()) {
|
||||
// Try to reuse an existing server
|
||||
try {
|
||||
const existing = readLiveServerInfo(process.cwd())?.info;
|
||||
const existing = readLiveServerInfo(cwd)?.info;
|
||||
if (existing && existing.pid) {
|
||||
try {
|
||||
process.kill(existing.pid, 0); // throws if dead
|
||||
@@ -232,7 +283,7 @@ function ensureServerRunning() {
|
||||
} catch { /* no PID file */ }
|
||||
|
||||
// Start a new server
|
||||
const out = runScript('live-server.mjs', ['--background']);
|
||||
const out = runScript('live-server.mjs', ['--background'], { cwd });
|
||||
return safeParse(out);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ Designs and iterates production-grade frontend interfaces. Real working code, co
|
||||
|
||||
You MUST do these steps before proceeding:
|
||||
|
||||
1. Run `node .trae-cn/skills/impeccable/scripts/context.mjs` once per session. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task.
|
||||
1. Run `node .trae-cn/skills/impeccable/scripts/context.mjs` once per session. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .trae-cn/skills/impeccable/scripts/context.mjs --target <path>` instead. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task.
|
||||
2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read `reference/<command>.md` next. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects.
|
||||
3. Familiarize yourself with any existing design system, conventions, and components in the code. Read at least one project file (CSS / tokens / theme / a representative component or page). **Required even when you've loaded a sub-command reference in step 2.** Don't reinvent the wheel; use what's there when it works, branch out when the UX wins.
|
||||
4. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md.
|
||||
|
||||
@@ -8,7 +8,7 @@ A running dev server with hot module replacement (Vite, Next.js, Bun, etc.), OR
|
||||
|
||||
Execute in order. No step skipped, no step reordered.
|
||||
|
||||
1. `live.mjs`: boot.
|
||||
1. `live.mjs`: boot. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .trae-cn/skills/impeccable/scripts/live.mjs --target <path>` instead; then run the rest of this live session from the returned `projectRoot`.
|
||||
2. Open the app URL that serves `pageFile` (infer from `package.json`, docs, terminal output, or an open tab). Never use `serverPort`; it's the helper, not the app. **Cursor:** `browser_navigate` to that URL before polling; do not skip. **Other harnesses:** use the available browser tool; if the URL is uncertain, ask the user once.
|
||||
3. Poll loop with the default long timeout (600000 ms). After every event or `--reply`, run `live-poll.mjs` again immediately. Never pass a short `--timeout=`.
|
||||
|
||||
|
||||
@@ -5,11 +5,12 @@
|
||||
* init flow.
|
||||
*
|
||||
* Path resolution (first match wins):
|
||||
* 1. cwd, if PRODUCT.md or DESIGN.md is there
|
||||
* 2. .agents/context/ then docs/
|
||||
* 3. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user
|
||||
* 1. Active project root, if PRODUCT.md or DESIGN.md is there
|
||||
* 2. Active project .agents/context/ then docs/
|
||||
* 3. Monorepo root context, using the same order, as a per-file fallback
|
||||
* 4. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user
|
||||
* escape hatch, only consulted when defaults are empty
|
||||
* 4. cwd as a "nothing found" default
|
||||
* 5. Active project root as a "nothing found" default
|
||||
*
|
||||
* `resolveContextDir()` and `loadContext()` are also exported for the
|
||||
* server-side scripts (live.mjs, live-server.mjs) that need the structured
|
||||
@@ -19,10 +20,25 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseTargetOptions } from './lib/target-args.mjs';
|
||||
|
||||
const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md'];
|
||||
const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md'];
|
||||
const FALLBACK_DIRS = ['.agents/context', 'docs'];
|
||||
const MONOREPO_MARKER_FILES = ['pnpm-workspace.yaml', 'turbo.json', 'nx.json', 'lerna.json'];
|
||||
const MONOREPO_FALLBACK_PROJECT_DIRS = ['apps', 'packages'];
|
||||
const WORKSPACE_DISCOVERY_IGNORED_DIRS = new Set([
|
||||
'node_modules',
|
||||
'.git',
|
||||
'dist',
|
||||
'build',
|
||||
'.next',
|
||||
'.nuxt',
|
||||
'.svelte-kit',
|
||||
'.turbo',
|
||||
'.cache',
|
||||
'coverage',
|
||||
]);
|
||||
|
||||
// ─── Update check ──────────────────────────────────────────────────────────
|
||||
// Piggyback a lightweight skill-version check on the once-per-session boot.
|
||||
@@ -38,41 +54,600 @@ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // throttle the network poll to o
|
||||
const RENOTIFY_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000; // don't re-surface the same version for a week
|
||||
const FETCH_TIMEOUT_MS = 1200;
|
||||
|
||||
export function resolveContextDir(cwd = process.cwd()) {
|
||||
if (firstExisting(cwd, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return cwd;
|
||||
}
|
||||
for (const rel of FALLBACK_DIRS) {
|
||||
const candidate = path.resolve(cwd, rel);
|
||||
if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
const envDir = process.env.IMPECCABLE_CONTEXT_DIR;
|
||||
if (envDir && envDir.trim()) {
|
||||
const trimmed = envDir.trim();
|
||||
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
||||
}
|
||||
return cwd;
|
||||
export function resolveContextDir(cwd = process.cwd(), options = {}) {
|
||||
return resolveContext(cwd, options).contextDir;
|
||||
}
|
||||
|
||||
export function loadContext(cwd = process.cwd()) {
|
||||
const contextDir = resolveContextDir(cwd);
|
||||
const productPath = firstExisting(contextDir, PRODUCT_NAMES);
|
||||
const designPath = firstExisting(contextDir, DESIGN_NAMES);
|
||||
export function loadContext(cwd = process.cwd(), options = {}) {
|
||||
const resolved = resolveContext(cwd, options);
|
||||
const absCwd = path.resolve(cwd);
|
||||
const productPath = resolved.productPath;
|
||||
const designPath = resolved.designPath;
|
||||
const product = productPath ? safeRead(productPath) : null;
|
||||
const design = designPath ? safeRead(designPath) : null;
|
||||
return {
|
||||
hasProduct: !!product,
|
||||
product,
|
||||
productPath: productPath ? path.relative(cwd, productPath) : null,
|
||||
productPath: productPath ? path.relative(absCwd, productPath) : null,
|
||||
hasDesign: !!design,
|
||||
design,
|
||||
designPath: designPath ? path.relative(cwd, designPath) : null,
|
||||
contextDir,
|
||||
designPath: designPath ? path.relative(absCwd, designPath) : null,
|
||||
contextDir: resolved.contextDir,
|
||||
productContextDir: productPath ? path.dirname(productPath) : null,
|
||||
designContextDir: designPath ? path.dirname(designPath) : null,
|
||||
projectRoot: resolved.projectRoot,
|
||||
repoRoot: resolved.repoRoot,
|
||||
isMonorepo: resolved.isMonorepo,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveContext(cwd = process.cwd(), options = {}) {
|
||||
const absCwd = path.resolve(cwd);
|
||||
const project = resolveProject(absCwd, options);
|
||||
const projectContextDir = resolveLocalContextDir(project.projectRoot);
|
||||
const rootContextDir = project.isMonorepo && project.repoRoot !== project.projectRoot
|
||||
? resolveLocalContextDir(project.repoRoot)
|
||||
: null;
|
||||
|
||||
let productPath =
|
||||
(projectContextDir ? firstExisting(projectContextDir, PRODUCT_NAMES) : null)
|
||||
|| (rootContextDir ? firstExisting(rootContextDir, PRODUCT_NAMES) : null);
|
||||
let designPath =
|
||||
(projectContextDir ? firstExisting(projectContextDir, DESIGN_NAMES) : null)
|
||||
|| (rootContextDir ? firstExisting(rootContextDir, DESIGN_NAMES) : null);
|
||||
|
||||
let envContextDir = null;
|
||||
if (!productPath && !designPath) {
|
||||
envContextDir = resolveEnvContextDir(absCwd);
|
||||
if (envContextDir) {
|
||||
productPath = firstExisting(envContextDir, PRODUCT_NAMES);
|
||||
designPath = firstExisting(envContextDir, DESIGN_NAMES);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
contextDir: productPath
|
||||
? path.dirname(productPath)
|
||||
: designPath
|
||||
? path.dirname(designPath)
|
||||
: envContextDir || project.projectRoot,
|
||||
productPath,
|
||||
designPath,
|
||||
projectRoot: project.projectRoot,
|
||||
repoRoot: project.repoRoot,
|
||||
isMonorepo: project.isMonorepo,
|
||||
targetDir: project.targetDir,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveProjectRoot(cwd = process.cwd(), options = {}) {
|
||||
return resolveProject(cwd, options).projectRoot;
|
||||
}
|
||||
|
||||
export function resolveTargetSelection(cwd = process.cwd(), options = {}) {
|
||||
if (hasTargetOption(options)) return null;
|
||||
const project = resolveProject(cwd);
|
||||
if (
|
||||
!project.isMonorepo
|
||||
|| !project.projectRoot
|
||||
|| !project.repoRoot
|
||||
|| path.resolve(project.projectRoot) !== path.resolve(project.repoRoot)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
targetPath: null,
|
||||
projectRoot: project.projectRoot,
|
||||
repoRoot: project.repoRoot,
|
||||
targetCandidates: discoverTargetCandidates(project.repoRoot),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveProject(cwd = process.cwd(), options = {}) {
|
||||
const absCwd = path.resolve(cwd);
|
||||
const targetDir = resolveTargetDir(absCwd, options);
|
||||
let repoRoot = findMonorepoRoot(targetDir);
|
||||
if (!repoRoot && targetDir !== absCwd) {
|
||||
const cwdRepoRoot = findMonorepoRoot(absCwd);
|
||||
if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {
|
||||
repoRoot = cwdRepoRoot;
|
||||
}
|
||||
}
|
||||
if (!repoRoot) {
|
||||
return {
|
||||
targetDir,
|
||||
projectRoot: absCwd,
|
||||
repoRoot: absCwd,
|
||||
isMonorepo: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
targetDir,
|
||||
projectRoot: resolveWorkspaceProjectRoot(repoRoot, targetDir) || repoRoot,
|
||||
repoRoot,
|
||||
isMonorepo: true,
|
||||
};
|
||||
}
|
||||
|
||||
function isPathInside(candidate, root) {
|
||||
const rel = path.relative(root, candidate);
|
||||
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
|
||||
}
|
||||
|
||||
function resolveLocalContextDir(root) {
|
||||
if (firstExisting(root, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return root;
|
||||
}
|
||||
for (const rel of FALLBACK_DIRS) {
|
||||
const candidate = path.resolve(root, rel);
|
||||
if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveEnvContextDir(cwd) {
|
||||
const envDir = process.env.IMPECCABLE_CONTEXT_DIR;
|
||||
if (!envDir || !envDir.trim()) return null;
|
||||
const trimmed = envDir.trim();
|
||||
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
||||
}
|
||||
|
||||
function resolveTargetDir(cwd, options = {}) {
|
||||
const targetPath = options && typeof options === 'object' ? options.targetPath : null;
|
||||
if (!targetPath || !String(targetPath).trim()) return cwd;
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
try {
|
||||
const stat = fs.statSync(abs);
|
||||
return stat.isDirectory() ? abs : path.dirname(abs);
|
||||
} catch {
|
||||
return path.extname(abs) ? path.dirname(abs) : abs;
|
||||
}
|
||||
}
|
||||
|
||||
function findMonorepoRoot(startDir) {
|
||||
let dir = path.resolve(startDir);
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
while (true) {
|
||||
if (dir === homeDir) return null;
|
||||
if (isMonorepoRoot(dir)) return dir;
|
||||
if (hasGitBoundary(dir)) return null;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function isMonorepoRoot(dir) {
|
||||
if (readWorkspacePatterns(dir).some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) return true;
|
||||
if (!MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(dir, file)))) return false;
|
||||
return hasFallbackWorkspaceChildren(dir);
|
||||
}
|
||||
|
||||
function hasGitBoundary(dir) {
|
||||
return fs.existsSync(path.join(dir, '.git'));
|
||||
}
|
||||
|
||||
function hasFallbackWorkspaceChildren(dir) {
|
||||
for (const name of MONOREPO_FALLBACK_PROJECT_DIRS) {
|
||||
const base = path.join(dir, name);
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(base, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (entries.some((entry) => entry.isDirectory() && !isIgnoredWorkspaceDiscoveryDir(entry.name))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function discoverTargetCandidates(repoRoot) {
|
||||
const roots = new Map();
|
||||
for (const pattern of readWorkspacePatterns(repoRoot)) {
|
||||
for (const root of discoverRootsForPattern(repoRoot, pattern)) {
|
||||
roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root);
|
||||
}
|
||||
}
|
||||
if (MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(repoRoot, file)))) {
|
||||
for (const name of MONOREPO_FALLBACK_PROJECT_DIRS) {
|
||||
const base = path.join(repoRoot, name);
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(base, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
||||
const root = path.join(base, entry.name);
|
||||
roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...roots.entries()]
|
||||
.filter(([rel]) => rel && !rel.startsWith('..'))
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([rel, root]) => {
|
||||
const targetExample = findTargetExample(repoRoot, root);
|
||||
return {
|
||||
name: path.basename(root),
|
||||
path: rel,
|
||||
targetExample,
|
||||
...resolveCandidateContextSummary(repoRoot, root, targetExample),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function resolveCandidateContextSummary(repoRoot, projectRoot, targetPath) {
|
||||
const ctx = resolveContext(repoRoot, { targetPath });
|
||||
return {
|
||||
productStatus: contextSourceStatus(ctx.productPath, repoRoot, projectRoot),
|
||||
productPath: contextSourcePath(ctx.productPath, repoRoot),
|
||||
designStatus: contextSourceStatus(ctx.designPath, repoRoot, projectRoot),
|
||||
designPath: contextSourcePath(ctx.designPath, repoRoot),
|
||||
};
|
||||
}
|
||||
|
||||
function contextSourceStatus(filePath, repoRoot, projectRoot) {
|
||||
if (!filePath) return 'missing';
|
||||
const absPath = path.resolve(filePath);
|
||||
const absProjectRoot = path.resolve(projectRoot);
|
||||
const absRepoRoot = path.resolve(repoRoot);
|
||||
if (isPathInsideOrEqual(absPath, absProjectRoot)) {
|
||||
return path.dirname(absPath) === absProjectRoot ? 'child' : 'fallback';
|
||||
}
|
||||
if (absProjectRoot !== absRepoRoot && isPathInsideOrEqual(absPath, absRepoRoot)) {
|
||||
return 'inherited';
|
||||
}
|
||||
return 'fallback';
|
||||
}
|
||||
|
||||
function contextSourcePath(filePath, repoRoot) {
|
||||
if (!filePath) return null;
|
||||
const rel = path.relative(repoRoot, filePath);
|
||||
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {
|
||||
return rel.split(path.sep).join('/');
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function discoverRootsForPattern(repoRoot, rawPattern) {
|
||||
const pattern = normalizeWorkspacePattern(rawPattern);
|
||||
if (!pattern || pattern.startsWith('!')) return [];
|
||||
const segments = pattern.split('/').filter(Boolean);
|
||||
if (!segments.length) return [];
|
||||
const firstGlobIndex = segments.findIndex((segment) => segment.includes('*'));
|
||||
const literalPrefix = firstGlobIndex === -1 ? segments : segments.slice(0, firstGlobIndex);
|
||||
const base = path.join(repoRoot, ...literalPrefix);
|
||||
if (!fs.existsSync(base)) return [];
|
||||
if (segments.includes('**')) {
|
||||
const packageRoots = [];
|
||||
walkDirs(base, (dir) => {
|
||||
if (dir !== base && isCandidateProjectRoot(dir)) packageRoots.push(dir);
|
||||
});
|
||||
if (packageRoots.length) return packageRoots;
|
||||
return directChildDirs(base);
|
||||
}
|
||||
return expandSimplePattern(repoRoot, segments);
|
||||
}
|
||||
|
||||
function expandSimplePattern(repoRoot, patternSegments, index = 0, current = repoRoot) {
|
||||
if (index >= patternSegments.length) return fs.existsSync(current) ? [current] : [];
|
||||
const segment = patternSegments[index];
|
||||
if (!segment.includes('*')) {
|
||||
return expandSimplePattern(repoRoot, patternSegments, index + 1, path.join(current, segment));
|
||||
}
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(current, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const roots = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
||||
if (!segmentMatches(segment, entry.name)) continue;
|
||||
roots.push(...expandSimplePattern(repoRoot, patternSegments, index + 1, path.join(current, entry.name)));
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
function directChildDirs(dir) {
|
||||
try {
|
||||
return fs.readdirSync(dir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() && !isIgnoredWorkspaceDiscoveryDir(entry.name))
|
||||
.map((entry) => path.join(dir, entry.name));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function walkDirs(root, visit) {
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(root, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
||||
const dir = path.join(root, entry.name);
|
||||
visit(dir);
|
||||
walkDirs(dir, visit);
|
||||
}
|
||||
}
|
||||
|
||||
function isCandidateProjectRoot(dir) {
|
||||
return !!(
|
||||
fs.existsSync(path.join(dir, 'package.json'))
|
||||
|| firstExisting(dir, [...PRODUCT_NAMES, ...DESIGN_NAMES])
|
||||
|| fs.existsSync(path.join(dir, 'src'))
|
||||
|| fs.existsSync(path.join(dir, 'app'))
|
||||
|| fs.existsSync(path.join(dir, 'pages'))
|
||||
|| fs.existsSync(path.join(dir, 'public'))
|
||||
);
|
||||
}
|
||||
|
||||
function isIgnoredWorkspaceDiscoveryDir(name) {
|
||||
return name.startsWith('.') || WORKSPACE_DISCOVERY_IGNORED_DIRS.has(name);
|
||||
}
|
||||
|
||||
function findTargetExample(repoRoot, projectRoot) {
|
||||
const examples = [
|
||||
'src/App.jsx',
|
||||
'src/App.tsx',
|
||||
'src/main.jsx',
|
||||
'src/main.tsx',
|
||||
'src/index.jsx',
|
||||
'src/index.ts',
|
||||
'app/page.tsx',
|
||||
'pages/index.tsx',
|
||||
'public/index.html',
|
||||
];
|
||||
for (const rel of examples) {
|
||||
const abs = path.join(projectRoot, rel);
|
||||
if (fs.existsSync(abs)) return path.relative(repoRoot, abs).split(path.sep).join('/');
|
||||
}
|
||||
return path.relative(repoRoot, projectRoot).split(path.sep).join('/');
|
||||
}
|
||||
|
||||
function resolveWorkspaceProjectRoot(repoRoot, targetDir) {
|
||||
const rel = path.relative(repoRoot, targetDir);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return repoRoot;
|
||||
const relSegments = rel.split(path.sep).filter(Boolean);
|
||||
const patterns = readWorkspacePatterns(repoRoot);
|
||||
const excluded = isExcludedByWorkspacePattern(relSegments, patterns);
|
||||
if (!excluded) {
|
||||
for (const pattern of patterns) {
|
||||
const projectRoot = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern);
|
||||
if (projectRoot) return projectRoot;
|
||||
}
|
||||
}
|
||||
if (excluded) return repoRoot;
|
||||
if (
|
||||
relSegments.length >= 2
|
||||
&& MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0])
|
||||
) {
|
||||
return path.join(repoRoot, relSegments[0], relSegments[1]);
|
||||
}
|
||||
const nearest = nearestProjectLikeRoot(repoRoot, targetDir);
|
||||
if (nearest) return nearest;
|
||||
return repoRoot;
|
||||
}
|
||||
|
||||
function isExcludedByWorkspacePattern(relSegments, patterns) {
|
||||
return patterns.some((rawPattern) => {
|
||||
const pattern = normalizeWorkspacePattern(rawPattern);
|
||||
if (!pattern.startsWith('!')) return false;
|
||||
return workspacePatternMatchesRel(pattern.slice(1), relSegments);
|
||||
});
|
||||
}
|
||||
|
||||
function nearestProjectLikeRoot(repoRoot, targetDir) {
|
||||
let dir = path.resolve(targetDir);
|
||||
const stop = path.resolve(repoRoot);
|
||||
while (dir && dir !== stop) {
|
||||
if (
|
||||
firstExisting(dir, [...PRODUCT_NAMES, ...DESIGN_NAMES])
|
||||
|| fs.existsSync(path.join(dir, 'package.json'))
|
||||
) {
|
||||
return dir;
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function nearestPackageRootBetween(repoRoot, targetDir, stopDir) {
|
||||
let dir = path.resolve(targetDir);
|
||||
const stop = path.resolve(stopDir || repoRoot);
|
||||
const root = path.resolve(repoRoot);
|
||||
while (dir && dir !== stop && isPathInsideOrEqual(dir, root)) {
|
||||
if (fs.existsSync(path.join(dir, 'package.json'))) return dir;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPathInsideOrEqual(candidate, root) {
|
||||
return path.resolve(candidate) === path.resolve(root) || isPathInside(candidate, root);
|
||||
}
|
||||
|
||||
function workspacePatternMatchesRel(pattern, relSegments) {
|
||||
const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return false;
|
||||
if (patternSegments.includes('**')) {
|
||||
const firstGlobIndex = patternSegments.findIndex((segment) => segment.includes('*'));
|
||||
const literalPrefix = firstGlobIndex === -1
|
||||
? patternSegments
|
||||
: patternSegments.slice(0, firstGlobIndex);
|
||||
if (relSegments.length < literalPrefix.length + 1) return false;
|
||||
for (let i = 0; i < literalPrefix.length; i++) {
|
||||
if (!segmentMatches(literalPrefix[i], relSegments[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (relSegments.length < patternSegments.length) return false;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function readWorkspacePatterns(repoRoot) {
|
||||
return [
|
||||
...readPackageWorkspaces(repoRoot),
|
||||
...readPnpmWorkspaces(repoRoot),
|
||||
...readLernaWorkspaces(repoRoot),
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
function readPackageWorkspaces(repoRoot) {
|
||||
const pkg = readJson(path.join(repoRoot, 'package.json'));
|
||||
const workspaces = pkg?.workspaces;
|
||||
if (Array.isArray(workspaces)) return workspaces;
|
||||
if (Array.isArray(workspaces?.packages)) return workspaces.packages;
|
||||
return [];
|
||||
}
|
||||
|
||||
function readLernaWorkspaces(repoRoot) {
|
||||
const lerna = readJson(path.join(repoRoot, 'lerna.json'));
|
||||
return Array.isArray(lerna?.packages) ? lerna.packages : [];
|
||||
}
|
||||
|
||||
function readPnpmWorkspaces(repoRoot) {
|
||||
try {
|
||||
const body = fs.readFileSync(path.join(repoRoot, 'pnpm-workspace.yaml'), 'utf-8');
|
||||
const patterns = [];
|
||||
let inPackages = false;
|
||||
for (const line of body.split(/\r?\n/)) {
|
||||
const trimmed = stripYamlInlineComment(line).trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const flowMatch = trimmed.match(/^packages:\s*\[(.*)\]\s*$/);
|
||||
if (flowMatch) {
|
||||
patterns.push(...parseYamlFlowList(flowMatch[1]));
|
||||
inPackages = false;
|
||||
continue;
|
||||
}
|
||||
if (/^packages:\s*$/.test(trimmed)) {
|
||||
inPackages = true;
|
||||
continue;
|
||||
}
|
||||
if (inPackages && /^[A-Za-z0-9_-]+:\s*/.test(trimmed)) break;
|
||||
if (inPackages) {
|
||||
const match = trimmed.match(/^-\s*(.+)$/);
|
||||
if (match) patterns.push(unquoteYamlValue(match[1]));
|
||||
}
|
||||
}
|
||||
return patterns;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function stripYamlInlineComment(line) {
|
||||
let quote = null;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
if ((ch === '"' || ch === "'") && line[i - 1] !== '\\') {
|
||||
quote = quote === ch ? null : quote || ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === '#' && !quote) return line.slice(0, i);
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
function parseYamlFlowList(body) {
|
||||
const items = [];
|
||||
let quote = null;
|
||||
let current = '';
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
const ch = body[i];
|
||||
if ((ch === '"' || ch === "'") && body[i - 1] !== '\\') {
|
||||
quote = quote === ch ? null : quote || ch;
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === ',' && !quote) {
|
||||
const value = unquoteYamlValue(current);
|
||||
if (value) items.push(value);
|
||||
current = '';
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
}
|
||||
const value = unquoteYamlValue(current);
|
||||
if (value) items.push(value);
|
||||
return items;
|
||||
}
|
||||
|
||||
function unquoteYamlValue(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '');
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function projectRootFromWorkspacePattern(repoRoot, relSegments, rawPattern) {
|
||||
const pattern = normalizeWorkspacePattern(rawPattern);
|
||||
if (!pattern || pattern.startsWith('!')) return null;
|
||||
const patternSegments = pattern.split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return null;
|
||||
if (patternSegments.includes('**')) {
|
||||
return projectRootFromDoubleStarPattern(repoRoot, relSegments, patternSegments);
|
||||
}
|
||||
if (relSegments.length < patternSegments.length) return null;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return null;
|
||||
}
|
||||
return path.join(repoRoot, ...relSegments.slice(0, patternSegments.length));
|
||||
}
|
||||
|
||||
function projectRootFromDoubleStarPattern(repoRoot, relSegments, patternSegments) {
|
||||
const firstGlobIndex = patternSegments.findIndex((segment) => segment.includes('*'));
|
||||
const literalPrefix = firstGlobIndex === -1
|
||||
? patternSegments
|
||||
: patternSegments.slice(0, firstGlobIndex);
|
||||
if (relSegments.length < literalPrefix.length + 1) return null;
|
||||
for (let i = 0; i < literalPrefix.length; i++) {
|
||||
if (!segmentMatches(literalPrefix[i], relSegments[i])) return null;
|
||||
}
|
||||
const prefixDir = path.join(repoRoot, ...literalPrefix);
|
||||
const targetDir = path.join(repoRoot, ...relSegments);
|
||||
const packageRoot = nearestPackageRootBetween(repoRoot, targetDir, prefixDir);
|
||||
if (packageRoot) return packageRoot;
|
||||
return path.join(repoRoot, ...relSegments.slice(0, literalPrefix.length + 1));
|
||||
}
|
||||
|
||||
function normalizeWorkspacePattern(pattern) {
|
||||
return String(pattern || '')
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '')
|
||||
.replace(/^\.\//, '')
|
||||
.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function segmentMatches(patternSegment, relSegment) {
|
||||
if (patternSegment === '*') return true;
|
||||
if (!patternSegment.includes('*')) return patternSegment === relSegment;
|
||||
const re = new RegExp(`^${escapeRegExp(patternSegment).replace(/\\\*/g, '[^/]*')}$`);
|
||||
return re.test(relSegment);
|
||||
}
|
||||
|
||||
function firstExisting(dir, names) {
|
||||
for (const name of names) {
|
||||
const abs = path.join(dir, name);
|
||||
@@ -89,6 +664,10 @@ function safeRead(p) {
|
||||
}
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the register (`brand` or `product`) out of PRODUCT.md by looking
|
||||
* for a `## Register` section and reading the first non-empty line that
|
||||
@@ -233,7 +812,24 @@ async function computeUpdateDirective(now = Date.now()) {
|
||||
}
|
||||
|
||||
async function cli() {
|
||||
const ctx = loadContext(process.cwd());
|
||||
let cliOptions;
|
||||
try {
|
||||
cliOptions = parseCliOptions(process.argv.slice(2));
|
||||
} catch (err) {
|
||||
if (err?.name === 'TargetArgError') {
|
||||
process.stderr.write(`${err.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const targetProvided = hasTargetOption(cliOptions);
|
||||
const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null;
|
||||
const selection = resolveTargetSelection(process.cwd(), cliOptions);
|
||||
if (selection) {
|
||||
process.stdout.write(buildTargetSelectionDirective(selection) + '\n');
|
||||
process.exit(0);
|
||||
}
|
||||
const ctx = loadContext(process.cwd(), cliOptions);
|
||||
const updateDirective = await computeUpdateDirective();
|
||||
|
||||
if (!ctx.hasProduct) {
|
||||
@@ -244,6 +840,10 @@ async function cli() {
|
||||
'Stop the current task, load reference/init.md, and follow its ' +
|
||||
'instructions to write PRODUCT.md before resuming.',
|
||||
];
|
||||
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
|
||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||
parts.push(buildMissingTargetDirective());
|
||||
}
|
||||
if (updateDirective) parts.push(updateDirective);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
process.exit(0);
|
||||
@@ -252,6 +852,10 @@ async function cli() {
|
||||
if (ctx.hasDesign) {
|
||||
parts.push(`# DESIGN.md\n\n${ctx.design.trim()}`);
|
||||
}
|
||||
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
|
||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||
parts.push(buildMissingTargetDirective());
|
||||
}
|
||||
const register = extractRegister(ctx.product);
|
||||
const next = register
|
||||
? `NEXT STEP: This project's register is \`${register}\`. You MUST now read \`reference/${register}.md\` before producing any design output.`
|
||||
@@ -261,6 +865,60 @@ async function cli() {
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
}
|
||||
|
||||
function parseCliOptions(args) {
|
||||
return parseTargetOptions(args, { strict: true });
|
||||
}
|
||||
|
||||
function hasTargetOption(options) {
|
||||
return !!(options && typeof options.targetPath === 'string' && options.targetPath.trim());
|
||||
}
|
||||
|
||||
function pathExistsForTarget(cwd, targetPath) {
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
return fs.existsSync(abs);
|
||||
}
|
||||
|
||||
function buildResolvedContextDirective(ctx, options, { targetExists = null } = {}) {
|
||||
const targetPath = hasTargetOption(options) ? options.targetPath : null;
|
||||
return `RESOLVED_CONTEXT:\n${JSON.stringify({
|
||||
targetPath,
|
||||
...(targetPath ? { targetExists } : {}),
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
productPath: ctx.productPath,
|
||||
designPath: ctx.designPath,
|
||||
}, null, 2)}`;
|
||||
}
|
||||
|
||||
function shouldWarnMissingTarget(ctx, targetProvided, targetExists = null) {
|
||||
if (ctx.isMonorepo && targetProvided && targetExists === false) return true;
|
||||
return !!(
|
||||
ctx.isMonorepo
|
||||
&& (!targetProvided || targetExists === false)
|
||||
&& ctx.projectRoot
|
||||
&& ctx.repoRoot
|
||||
&& path.resolve(ctx.projectRoot) === path.resolve(ctx.repoRoot)
|
||||
);
|
||||
}
|
||||
|
||||
function buildMissingTargetDirective() {
|
||||
const script = process.argv[1] || 'context.mjs';
|
||||
return (
|
||||
'MONOREPO_TARGET_REQUIRED: This is a monorepo and context.mjs ran without --target. ' +
|
||||
'If the user named a file, route, or child app, do not answer from this output. ' +
|
||||
`Rerun \`node ${script} --target <path>\` and answer from that run's RESOLVED_CONTEXT fields.`
|
||||
);
|
||||
}
|
||||
|
||||
function buildTargetSelectionDirective(selection) {
|
||||
return (
|
||||
`TARGET_SELECTION_REQUIRED:\n${JSON.stringify(selection, null, 2)}\n\n` +
|
||||
'Show each app with its productStatus/productPath and designStatus/designPath so the user can see child overrides, inherited root files, fallback files, or missing files before choosing. ' +
|
||||
'Ask the user which app Impeccable should use, then rerun Impeccable helper commands from that child app cwd using this same scripts directory. ' +
|
||||
'Use `--target <path>` only as a fallback when changing cwd is not possible, or when the user explicitly named a file/path.'
|
||||
);
|
||||
}
|
||||
|
||||
// Run cli() only when this module is the entry point. Compare realpaths
|
||||
// rather than endsWith(): a loose suffix match also fires for unrelated
|
||||
// scripts like `load-context.mjs`, and realpath tolerates symlinked
|
||||
|
||||
@@ -1,50 +1,52 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { resolveProjectRoot } from '../context.mjs';
|
||||
|
||||
export const IMPECCABLE_DIR = '.impeccable';
|
||||
export const LIVE_DIR = 'live';
|
||||
export const CRITIQUE_DIR = 'critique';
|
||||
|
||||
export function getImpeccableDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, IMPECCABLE_DIR);
|
||||
export function getImpeccableDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), IMPECCABLE_DIR);
|
||||
}
|
||||
|
||||
export function getDesignSidecarPath(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), 'design.json');
|
||||
export function getDesignSidecarPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), 'design.json');
|
||||
}
|
||||
|
||||
export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd) {
|
||||
export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd, options = {}) {
|
||||
const projectRoot = resolveProjectRoot(cwd, options);
|
||||
const candidates = [
|
||||
getDesignSidecarPath(cwd),
|
||||
path.join(cwd, 'DESIGN.json'),
|
||||
getDesignSidecarPath(cwd, options),
|
||||
path.join(projectRoot, 'DESIGN.json'),
|
||||
];
|
||||
const contextLegacy = path.join(contextDir, 'DESIGN.json');
|
||||
if (!candidates.includes(contextLegacy)) candidates.push(contextLegacy);
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) {
|
||||
return firstExisting(getDesignSidecarCandidates(cwd, contextDir));
|
||||
export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd, options = {}) {
|
||||
return firstExisting(getDesignSidecarCandidates(cwd, contextDir, options));
|
||||
}
|
||||
|
||||
export function getLiveDir(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), LIVE_DIR);
|
||||
export function getLiveDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), LIVE_DIR);
|
||||
}
|
||||
|
||||
export function getLiveConfigPath(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'config.json');
|
||||
export function getLiveConfigPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'config.json');
|
||||
}
|
||||
|
||||
export function getLegacyLiveConfigPath(scriptsDir) {
|
||||
return path.join(scriptsDir, 'config.json');
|
||||
}
|
||||
|
||||
export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env } = {}) {
|
||||
export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env, targetPath } = {}) {
|
||||
if (env.IMPECCABLE_LIVE_CONFIG && env.IMPECCABLE_LIVE_CONFIG.trim()) {
|
||||
const configured = env.IMPECCABLE_LIVE_CONFIG.trim();
|
||||
return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured);
|
||||
}
|
||||
const primary = getLiveConfigPath(cwd);
|
||||
const primary = getLiveConfigPath(cwd, { targetPath });
|
||||
if (fs.existsSync(primary)) return primary;
|
||||
if (scriptsDir) {
|
||||
const legacy = getLegacyLiveConfigPath(scriptsDir);
|
||||
@@ -53,16 +55,16 @@ export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = p
|
||||
return primary;
|
||||
}
|
||||
|
||||
export function getLiveServerPath(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'server.json');
|
||||
export function getLiveServerPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'server.json');
|
||||
}
|
||||
|
||||
export function getLegacyLiveServerPath(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live.json');
|
||||
export function getLegacyLiveServerPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live.json');
|
||||
}
|
||||
|
||||
export function readLiveServerInfo(cwd = process.cwd()) {
|
||||
for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) {
|
||||
export function readLiveServerInfo(cwd = process.cwd(), options = {}) {
|
||||
for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) {
|
||||
try {
|
||||
const info = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
if (info && typeof info.pid === 'number' && !isLiveServerPidReachable(info.pid)) {
|
||||
@@ -88,37 +90,37 @@ export function isLiveServerPidReachable(pid) {
|
||||
}
|
||||
}
|
||||
|
||||
export function writeLiveServerInfo(cwd = process.cwd(), info) {
|
||||
const filePath = getLiveServerPath(cwd);
|
||||
export function writeLiveServerInfo(cwd = process.cwd(), info, options = {}) {
|
||||
const filePath = getLiveServerPath(cwd, options);
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(info));
|
||||
return filePath;
|
||||
}
|
||||
|
||||
export function removeLiveServerInfo(cwd = process.cwd()) {
|
||||
for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) {
|
||||
export function removeLiveServerInfo(cwd = process.cwd(), options = {}) {
|
||||
for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) {
|
||||
try { fs.unlinkSync(filePath); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
export function getLiveSessionsDir(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'sessions');
|
||||
export function getLiveSessionsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'sessions');
|
||||
}
|
||||
|
||||
export function getLegacyLiveSessionsDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live', 'sessions');
|
||||
export function getLegacyLiveSessionsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'sessions');
|
||||
}
|
||||
|
||||
export function getLiveAnnotationsDir(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'annotations');
|
||||
export function getLiveAnnotationsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'annotations');
|
||||
}
|
||||
|
||||
export function getCritiqueDir(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), CRITIQUE_DIR);
|
||||
export function getCritiqueDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), CRITIQUE_DIR);
|
||||
}
|
||||
|
||||
export function getLegacyLiveAnnotationsDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live', 'annotations');
|
||||
export function getLegacyLiveAnnotationsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'annotations');
|
||||
}
|
||||
|
||||
function firstExisting(paths) {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
class TargetArgError extends Error {
|
||||
constructor(message, code) {
|
||||
super(message);
|
||||
this.name = 'TargetArgError';
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseTargetPath(args = [], { strict = false } = {}) {
|
||||
let targetPath = null;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = String(args[i]);
|
||||
if (arg === '--target' || arg === '-t') {
|
||||
const next = args[i + 1];
|
||||
if (next && !String(next).startsWith('-')) {
|
||||
targetPath = String(next);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (strict) {
|
||||
throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith('--target=')) {
|
||||
const value = arg.slice('--target='.length);
|
||||
if (value) {
|
||||
targetPath = value;
|
||||
continue;
|
||||
}
|
||||
if (strict) {
|
||||
throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING');
|
||||
}
|
||||
}
|
||||
}
|
||||
return targetPath;
|
||||
}
|
||||
|
||||
export function parseTargetOptions(args = [], options = {}) {
|
||||
const targetPath = parseTargetPath(args, options);
|
||||
return targetPath ? { targetPath } : {};
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import path from 'node:path';
|
||||
import net from 'node:net';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseDesignMd } from './lib/design-parser.mjs';
|
||||
import { resolveContextDir } from './context.mjs';
|
||||
import { loadContext } from './context.mjs';
|
||||
import {
|
||||
assembleLiveBrowserScript,
|
||||
assertLiveBrowserScriptParts,
|
||||
@@ -55,7 +55,11 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
// PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
|
||||
// DESIGN sidecar is project-local at .impeccable/design.json, with legacy
|
||||
// DESIGN.json fallback for existing projects.
|
||||
const CONTEXT_DIR = resolveContextDir(process.cwd());
|
||||
const PROJECT_CONTEXT = loadContext(process.cwd());
|
||||
const CONTEXT_DIR = PROJECT_CONTEXT.contextDir;
|
||||
const DESIGN_MD_PATH = PROJECT_CONTEXT.designPath
|
||||
? path.resolve(process.cwd(), PROJECT_CONTEXT.designPath)
|
||||
: null;
|
||||
const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway
|
||||
const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s
|
||||
|
||||
@@ -371,10 +375,7 @@ function hasProjectContext() {
|
||||
// PRODUCT.md carries brand voice / anti-references — that's what determines
|
||||
// whether variants are brand-aware. DESIGN.md (visual tokens) is a separate
|
||||
// concern, surfaced by the design panel's own empty state.
|
||||
try {
|
||||
fs.accessSync(path.join(CONTEXT_DIR, 'PRODUCT.md'), fs.constants.R_OK);
|
||||
return true;
|
||||
} catch { return false; }
|
||||
return !!PROJECT_CONTEXT.hasProduct;
|
||||
}
|
||||
|
||||
function statOrNull(filePath) {
|
||||
@@ -549,8 +550,8 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
const token = url.searchParams.get('token');
|
||||
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
|
||||
|
||||
const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md');
|
||||
const jsonPath = resolveDesignSidecarPath(process.cwd(), CONTEXT_DIR) || getDesignSidecarPath(process.cwd());
|
||||
const mdPath = DESIGN_MD_PATH;
|
||||
const jsonPath = resolveDesignSidecarPath(process.cwd(), PROJECT_CONTEXT.designContextDir || CONTEXT_DIR) || getDesignSidecarPath(process.cwd());
|
||||
const mdStat = statOrNull(mdPath);
|
||||
const jsonStat = statOrNull(jsonPath);
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import path from 'node:path';
|
||||
import { resolveProjectRoot } from './context.mjs';
|
||||
import { parseTargetPath } from './lib/target-args.mjs';
|
||||
|
||||
export function resolveLiveTarget(cwd = process.cwd(), args = []) {
|
||||
const originalCwd = path.resolve(cwd);
|
||||
let targetPath = null;
|
||||
try {
|
||||
targetPath = parseTargetPath(args, { strict: true });
|
||||
} catch (err) {
|
||||
if (err?.name === 'TargetArgError') {
|
||||
process.stderr.write(`${err.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const absoluteTargetPath = targetPath
|
||||
? path.isAbsolute(targetPath) ? targetPath : path.resolve(originalCwd, targetPath)
|
||||
: null;
|
||||
const projectRoot = targetPath
|
||||
? resolveProjectRoot(originalCwd, { targetPath: absoluteTargetPath })
|
||||
: originalCwd;
|
||||
return {
|
||||
originalCwd,
|
||||
projectRoot,
|
||||
targetPath,
|
||||
absoluteTargetPath,
|
||||
targetOptions: absoluteTargetPath ? { targetPath: absoluteTargetPath } : {},
|
||||
};
|
||||
}
|
||||
@@ -21,14 +21,16 @@ import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadContext } from './context.mjs';
|
||||
import { loadContext, resolveTargetSelection } from './context.mjs';
|
||||
import { resolveFiles } from './live-inject.mjs';
|
||||
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
|
||||
import { resolveLiveTarget } from './live-target.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
async function liveCli() {
|
||||
const args = process.argv.slice(2);
|
||||
const liveTarget = resolveLiveTarget(process.cwd(), args);
|
||||
|
||||
if (args.includes('--help') || args.includes('-h')) {
|
||||
console.log(`Usage: node live.mjs
|
||||
@@ -38,37 +40,78 @@ Prepare everything for live variant mode in a single command:
|
||||
- Starts (or reuses) the live server in the background
|
||||
- Injects the browser script tag
|
||||
- Reads PRODUCT.md / DESIGN.md for project context
|
||||
- In monorepos, choose a child app first; --target <path> is the fallback/manual path
|
||||
|
||||
On success, prints a JSON blob with:
|
||||
{ ok, serverPort, serverToken, pageFile, hasContext, context }
|
||||
{ ok, serverPort, serverToken, pageFiles, projectRoot, repoRoot, targetPath, productPath, designPath }
|
||||
|
||||
On target_selection_required, prints:
|
||||
{ ok: false, error: "target_selection_required", targetCandidates }
|
||||
|
||||
On config_missing, prints:
|
||||
{ ok: false, error: "config_missing", configPath, hint }
|
||||
|
||||
The agent should then:
|
||||
1. If config_missing, create the config and re-run this script
|
||||
2. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort)
|
||||
3. Enter the poll loop: node live-poll.mjs`);
|
||||
1. If target_selection_required, ask which app to use and rerun from that child cwd
|
||||
2. If config_missing, create the config and re-run this script
|
||||
3. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort)
|
||||
4. Enter the poll loop: node live-poll.mjs`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const targetSelection = resolveTargetSelection(liveTarget.originalCwd, liveTarget.targetOptions);
|
||||
if (targetSelection) {
|
||||
console.log(JSON.stringify({
|
||||
ok: false,
|
||||
error: 'target_selection_required',
|
||||
...targetSelection,
|
||||
hint: 'Ask the user which app Impeccable should use, then rerun live from that child app cwd. Use --target <path> only as a fallback or explicit path diagnostic.',
|
||||
}, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const ctx = loadContext(liveTarget.originalCwd, liveTarget.targetOptions);
|
||||
const activeCwd = ctx.projectRoot;
|
||||
const outputTargetPath = liveTarget.targetPath || null;
|
||||
|
||||
const missingContext = missingLiveContext(ctx);
|
||||
if (missingContext.length > 0) {
|
||||
console.log(JSON.stringify({
|
||||
ok: false,
|
||||
error: 'context_missing',
|
||||
missing: missingContext,
|
||||
nextCommand: missingContext.includes('PRODUCT.md') ? 'init' : 'document',
|
||||
targetPath: outputTargetPath,
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
productPath: ctx.productPath,
|
||||
designPath: ctx.designPath,
|
||||
}, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 1. Check config (fail fast if missing — no point starting anything else)
|
||||
const checkOut = runScript('live-inject.mjs', ['--check']);
|
||||
const checkOut = runScript('live-inject.mjs', ['--check'], { cwd: activeCwd });
|
||||
const checkResult = safeParse(checkOut);
|
||||
if (!checkResult || !checkResult.ok) {
|
||||
console.log(JSON.stringify(checkResult || { ok: false, error: 'check_failed', raw: checkOut }));
|
||||
console.log(JSON.stringify({
|
||||
...(checkResult || { ok: false, error: 'check_failed', raw: checkOut }),
|
||||
targetPath: outputTargetPath,
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
}));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 2. Start server (or reuse existing)
|
||||
const serverInfo = ensureServerRunning();
|
||||
const serverInfo = ensureServerRunning(activeCwd);
|
||||
if (!serverInfo) {
|
||||
console.log(JSON.stringify({ ok: false, error: 'server_start_failed' }));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 3. Inject the script tag at the current port
|
||||
const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)]);
|
||||
const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)], { cwd: activeCwd });
|
||||
const injectResult = safeParse(injectOut);
|
||||
if (!injectResult || !injectResult.ok) {
|
||||
console.log(JSON.stringify({
|
||||
@@ -80,22 +123,23 @@ The agent should then:
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 4. Load PRODUCT.md + DESIGN.md context.
|
||||
const ctx = loadContext(process.cwd());
|
||||
|
||||
// 5. Compute drift-heal: compare resolved inject targets against the
|
||||
// 4. 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);
|
||||
const resolvedFiles = resolveFiles(activeCwd, checkResult.config);
|
||||
const drift = scanForDrift(activeCwd, resolvedFiles, checkResult.config);
|
||||
|
||||
// 6. Emit everything the agent needs
|
||||
// 5. Emit everything the agent needs
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
serverPort: serverInfo.port,
|
||||
serverToken: serverInfo.token,
|
||||
pageFiles: resolvedFiles,
|
||||
liveConfigPath: checkResult.path,
|
||||
configDrift: drift,
|
||||
targetPath: outputTargetPath,
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
hasProduct: ctx.hasProduct,
|
||||
product: ctx.product,
|
||||
productPath: ctx.productPath,
|
||||
@@ -105,6 +149,13 @@ The agent should then:
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
function missingLiveContext(ctx) {
|
||||
const missing = [];
|
||||
if (!ctx.hasProduct) missing.push('PRODUCT.md');
|
||||
if (!ctx.hasDesign) missing.push('DESIGN.md');
|
||||
return missing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drift-heal scan. Walks the project for HTML files under common
|
||||
* page-source directories (public/, src/, app/, pages/) and reports any
|
||||
@@ -201,11 +252,11 @@ function globToRegex(pattern) {
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function runScript(name, args) {
|
||||
function runScript(name, args, options = {}) {
|
||||
const scriptPath = path.join(__dirname, name);
|
||||
const cmd = `node "${scriptPath}" ${args.map(a => `"${a}"`).join(' ')}`;
|
||||
try {
|
||||
return execSync(cmd, { encoding: 'utf-8', cwd: process.cwd(), timeout: 15_000 });
|
||||
return execSync(cmd, { encoding: 'utf-8', cwd: options.cwd || process.cwd(), timeout: 15_000 });
|
||||
} catch (err) {
|
||||
// execSync throws on non-zero exit; return stdout if any
|
||||
return err.stdout || err.message || '';
|
||||
@@ -219,10 +270,10 @@ function safeParse(out) {
|
||||
/**
|
||||
* Return { pid, port, token } for the running live server, starting one if needed.
|
||||
*/
|
||||
function ensureServerRunning() {
|
||||
function ensureServerRunning(cwd = process.cwd()) {
|
||||
// Try to reuse an existing server
|
||||
try {
|
||||
const existing = readLiveServerInfo(process.cwd())?.info;
|
||||
const existing = readLiveServerInfo(cwd)?.info;
|
||||
if (existing && existing.pid) {
|
||||
try {
|
||||
process.kill(existing.pid, 0); // throws if dead
|
||||
@@ -232,7 +283,7 @@ function ensureServerRunning() {
|
||||
} catch { /* no PID file */ }
|
||||
|
||||
// Start a new server
|
||||
const out = runScript('live-server.mjs', ['--background']);
|
||||
const out = runScript('live-server.mjs', ['--background'], { cwd });
|
||||
return safeParse(out);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ Designs and iterates production-grade frontend interfaces. Real working code, co
|
||||
|
||||
You MUST do these steps before proceeding:
|
||||
|
||||
1. Run `node .trae/skills/impeccable/scripts/context.mjs` once per session. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task.
|
||||
1. Run `node .trae/skills/impeccable/scripts/context.mjs` once per session. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .trae/skills/impeccable/scripts/context.mjs --target <path>` instead. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task.
|
||||
2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read `reference/<command>.md` next. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects.
|
||||
3. Familiarize yourself with any existing design system, conventions, and components in the code. Read at least one project file (CSS / tokens / theme / a representative component or page). **Required even when you've loaded a sub-command reference in step 2.** Don't reinvent the wheel; use what's there when it works, branch out when the UX wins.
|
||||
4. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md.
|
||||
|
||||
@@ -8,7 +8,7 @@ A running dev server with hot module replacement (Vite, Next.js, Bun, etc.), OR
|
||||
|
||||
Execute in order. No step skipped, no step reordered.
|
||||
|
||||
1. `live.mjs`: boot.
|
||||
1. `live.mjs`: boot. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .trae/skills/impeccable/scripts/live.mjs --target <path>` instead; then run the rest of this live session from the returned `projectRoot`.
|
||||
2. Open the app URL that serves `pageFile` (infer from `package.json`, docs, terminal output, or an open tab). Never use `serverPort`; it's the helper, not the app. **Cursor:** `browser_navigate` to that URL before polling; do not skip. **Other harnesses:** use the available browser tool; if the URL is uncertain, ask the user once.
|
||||
3. Poll loop with the default long timeout (600000 ms). After every event or `--reply`, run `live-poll.mjs` again immediately. Never pass a short `--timeout=`.
|
||||
|
||||
|
||||
@@ -5,11 +5,12 @@
|
||||
* init flow.
|
||||
*
|
||||
* Path resolution (first match wins):
|
||||
* 1. cwd, if PRODUCT.md or DESIGN.md is there
|
||||
* 2. .agents/context/ then docs/
|
||||
* 3. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user
|
||||
* 1. Active project root, if PRODUCT.md or DESIGN.md is there
|
||||
* 2. Active project .agents/context/ then docs/
|
||||
* 3. Monorepo root context, using the same order, as a per-file fallback
|
||||
* 4. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user
|
||||
* escape hatch, only consulted when defaults are empty
|
||||
* 4. cwd as a "nothing found" default
|
||||
* 5. Active project root as a "nothing found" default
|
||||
*
|
||||
* `resolveContextDir()` and `loadContext()` are also exported for the
|
||||
* server-side scripts (live.mjs, live-server.mjs) that need the structured
|
||||
@@ -19,10 +20,25 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseTargetOptions } from './lib/target-args.mjs';
|
||||
|
||||
const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md'];
|
||||
const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md'];
|
||||
const FALLBACK_DIRS = ['.agents/context', 'docs'];
|
||||
const MONOREPO_MARKER_FILES = ['pnpm-workspace.yaml', 'turbo.json', 'nx.json', 'lerna.json'];
|
||||
const MONOREPO_FALLBACK_PROJECT_DIRS = ['apps', 'packages'];
|
||||
const WORKSPACE_DISCOVERY_IGNORED_DIRS = new Set([
|
||||
'node_modules',
|
||||
'.git',
|
||||
'dist',
|
||||
'build',
|
||||
'.next',
|
||||
'.nuxt',
|
||||
'.svelte-kit',
|
||||
'.turbo',
|
||||
'.cache',
|
||||
'coverage',
|
||||
]);
|
||||
|
||||
// ─── Update check ──────────────────────────────────────────────────────────
|
||||
// Piggyback a lightweight skill-version check on the once-per-session boot.
|
||||
@@ -38,41 +54,600 @@ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // throttle the network poll to o
|
||||
const RENOTIFY_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000; // don't re-surface the same version for a week
|
||||
const FETCH_TIMEOUT_MS = 1200;
|
||||
|
||||
export function resolveContextDir(cwd = process.cwd()) {
|
||||
if (firstExisting(cwd, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return cwd;
|
||||
}
|
||||
for (const rel of FALLBACK_DIRS) {
|
||||
const candidate = path.resolve(cwd, rel);
|
||||
if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
const envDir = process.env.IMPECCABLE_CONTEXT_DIR;
|
||||
if (envDir && envDir.trim()) {
|
||||
const trimmed = envDir.trim();
|
||||
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
||||
}
|
||||
return cwd;
|
||||
export function resolveContextDir(cwd = process.cwd(), options = {}) {
|
||||
return resolveContext(cwd, options).contextDir;
|
||||
}
|
||||
|
||||
export function loadContext(cwd = process.cwd()) {
|
||||
const contextDir = resolveContextDir(cwd);
|
||||
const productPath = firstExisting(contextDir, PRODUCT_NAMES);
|
||||
const designPath = firstExisting(contextDir, DESIGN_NAMES);
|
||||
export function loadContext(cwd = process.cwd(), options = {}) {
|
||||
const resolved = resolveContext(cwd, options);
|
||||
const absCwd = path.resolve(cwd);
|
||||
const productPath = resolved.productPath;
|
||||
const designPath = resolved.designPath;
|
||||
const product = productPath ? safeRead(productPath) : null;
|
||||
const design = designPath ? safeRead(designPath) : null;
|
||||
return {
|
||||
hasProduct: !!product,
|
||||
product,
|
||||
productPath: productPath ? path.relative(cwd, productPath) : null,
|
||||
productPath: productPath ? path.relative(absCwd, productPath) : null,
|
||||
hasDesign: !!design,
|
||||
design,
|
||||
designPath: designPath ? path.relative(cwd, designPath) : null,
|
||||
contextDir,
|
||||
designPath: designPath ? path.relative(absCwd, designPath) : null,
|
||||
contextDir: resolved.contextDir,
|
||||
productContextDir: productPath ? path.dirname(productPath) : null,
|
||||
designContextDir: designPath ? path.dirname(designPath) : null,
|
||||
projectRoot: resolved.projectRoot,
|
||||
repoRoot: resolved.repoRoot,
|
||||
isMonorepo: resolved.isMonorepo,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveContext(cwd = process.cwd(), options = {}) {
|
||||
const absCwd = path.resolve(cwd);
|
||||
const project = resolveProject(absCwd, options);
|
||||
const projectContextDir = resolveLocalContextDir(project.projectRoot);
|
||||
const rootContextDir = project.isMonorepo && project.repoRoot !== project.projectRoot
|
||||
? resolveLocalContextDir(project.repoRoot)
|
||||
: null;
|
||||
|
||||
let productPath =
|
||||
(projectContextDir ? firstExisting(projectContextDir, PRODUCT_NAMES) : null)
|
||||
|| (rootContextDir ? firstExisting(rootContextDir, PRODUCT_NAMES) : null);
|
||||
let designPath =
|
||||
(projectContextDir ? firstExisting(projectContextDir, DESIGN_NAMES) : null)
|
||||
|| (rootContextDir ? firstExisting(rootContextDir, DESIGN_NAMES) : null);
|
||||
|
||||
let envContextDir = null;
|
||||
if (!productPath && !designPath) {
|
||||
envContextDir = resolveEnvContextDir(absCwd);
|
||||
if (envContextDir) {
|
||||
productPath = firstExisting(envContextDir, PRODUCT_NAMES);
|
||||
designPath = firstExisting(envContextDir, DESIGN_NAMES);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
contextDir: productPath
|
||||
? path.dirname(productPath)
|
||||
: designPath
|
||||
? path.dirname(designPath)
|
||||
: envContextDir || project.projectRoot,
|
||||
productPath,
|
||||
designPath,
|
||||
projectRoot: project.projectRoot,
|
||||
repoRoot: project.repoRoot,
|
||||
isMonorepo: project.isMonorepo,
|
||||
targetDir: project.targetDir,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveProjectRoot(cwd = process.cwd(), options = {}) {
|
||||
return resolveProject(cwd, options).projectRoot;
|
||||
}
|
||||
|
||||
export function resolveTargetSelection(cwd = process.cwd(), options = {}) {
|
||||
if (hasTargetOption(options)) return null;
|
||||
const project = resolveProject(cwd);
|
||||
if (
|
||||
!project.isMonorepo
|
||||
|| !project.projectRoot
|
||||
|| !project.repoRoot
|
||||
|| path.resolve(project.projectRoot) !== path.resolve(project.repoRoot)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
targetPath: null,
|
||||
projectRoot: project.projectRoot,
|
||||
repoRoot: project.repoRoot,
|
||||
targetCandidates: discoverTargetCandidates(project.repoRoot),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveProject(cwd = process.cwd(), options = {}) {
|
||||
const absCwd = path.resolve(cwd);
|
||||
const targetDir = resolveTargetDir(absCwd, options);
|
||||
let repoRoot = findMonorepoRoot(targetDir);
|
||||
if (!repoRoot && targetDir !== absCwd) {
|
||||
const cwdRepoRoot = findMonorepoRoot(absCwd);
|
||||
if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {
|
||||
repoRoot = cwdRepoRoot;
|
||||
}
|
||||
}
|
||||
if (!repoRoot) {
|
||||
return {
|
||||
targetDir,
|
||||
projectRoot: absCwd,
|
||||
repoRoot: absCwd,
|
||||
isMonorepo: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
targetDir,
|
||||
projectRoot: resolveWorkspaceProjectRoot(repoRoot, targetDir) || repoRoot,
|
||||
repoRoot,
|
||||
isMonorepo: true,
|
||||
};
|
||||
}
|
||||
|
||||
function isPathInside(candidate, root) {
|
||||
const rel = path.relative(root, candidate);
|
||||
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
|
||||
}
|
||||
|
||||
function resolveLocalContextDir(root) {
|
||||
if (firstExisting(root, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return root;
|
||||
}
|
||||
for (const rel of FALLBACK_DIRS) {
|
||||
const candidate = path.resolve(root, rel);
|
||||
if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveEnvContextDir(cwd) {
|
||||
const envDir = process.env.IMPECCABLE_CONTEXT_DIR;
|
||||
if (!envDir || !envDir.trim()) return null;
|
||||
const trimmed = envDir.trim();
|
||||
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
||||
}
|
||||
|
||||
function resolveTargetDir(cwd, options = {}) {
|
||||
const targetPath = options && typeof options === 'object' ? options.targetPath : null;
|
||||
if (!targetPath || !String(targetPath).trim()) return cwd;
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
try {
|
||||
const stat = fs.statSync(abs);
|
||||
return stat.isDirectory() ? abs : path.dirname(abs);
|
||||
} catch {
|
||||
return path.extname(abs) ? path.dirname(abs) : abs;
|
||||
}
|
||||
}
|
||||
|
||||
function findMonorepoRoot(startDir) {
|
||||
let dir = path.resolve(startDir);
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
while (true) {
|
||||
if (dir === homeDir) return null;
|
||||
if (isMonorepoRoot(dir)) return dir;
|
||||
if (hasGitBoundary(dir)) return null;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function isMonorepoRoot(dir) {
|
||||
if (readWorkspacePatterns(dir).some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) return true;
|
||||
if (!MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(dir, file)))) return false;
|
||||
return hasFallbackWorkspaceChildren(dir);
|
||||
}
|
||||
|
||||
function hasGitBoundary(dir) {
|
||||
return fs.existsSync(path.join(dir, '.git'));
|
||||
}
|
||||
|
||||
function hasFallbackWorkspaceChildren(dir) {
|
||||
for (const name of MONOREPO_FALLBACK_PROJECT_DIRS) {
|
||||
const base = path.join(dir, name);
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(base, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (entries.some((entry) => entry.isDirectory() && !isIgnoredWorkspaceDiscoveryDir(entry.name))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function discoverTargetCandidates(repoRoot) {
|
||||
const roots = new Map();
|
||||
for (const pattern of readWorkspacePatterns(repoRoot)) {
|
||||
for (const root of discoverRootsForPattern(repoRoot, pattern)) {
|
||||
roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root);
|
||||
}
|
||||
}
|
||||
if (MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(repoRoot, file)))) {
|
||||
for (const name of MONOREPO_FALLBACK_PROJECT_DIRS) {
|
||||
const base = path.join(repoRoot, name);
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(base, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
||||
const root = path.join(base, entry.name);
|
||||
roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...roots.entries()]
|
||||
.filter(([rel]) => rel && !rel.startsWith('..'))
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([rel, root]) => {
|
||||
const targetExample = findTargetExample(repoRoot, root);
|
||||
return {
|
||||
name: path.basename(root),
|
||||
path: rel,
|
||||
targetExample,
|
||||
...resolveCandidateContextSummary(repoRoot, root, targetExample),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function resolveCandidateContextSummary(repoRoot, projectRoot, targetPath) {
|
||||
const ctx = resolveContext(repoRoot, { targetPath });
|
||||
return {
|
||||
productStatus: contextSourceStatus(ctx.productPath, repoRoot, projectRoot),
|
||||
productPath: contextSourcePath(ctx.productPath, repoRoot),
|
||||
designStatus: contextSourceStatus(ctx.designPath, repoRoot, projectRoot),
|
||||
designPath: contextSourcePath(ctx.designPath, repoRoot),
|
||||
};
|
||||
}
|
||||
|
||||
function contextSourceStatus(filePath, repoRoot, projectRoot) {
|
||||
if (!filePath) return 'missing';
|
||||
const absPath = path.resolve(filePath);
|
||||
const absProjectRoot = path.resolve(projectRoot);
|
||||
const absRepoRoot = path.resolve(repoRoot);
|
||||
if (isPathInsideOrEqual(absPath, absProjectRoot)) {
|
||||
return path.dirname(absPath) === absProjectRoot ? 'child' : 'fallback';
|
||||
}
|
||||
if (absProjectRoot !== absRepoRoot && isPathInsideOrEqual(absPath, absRepoRoot)) {
|
||||
return 'inherited';
|
||||
}
|
||||
return 'fallback';
|
||||
}
|
||||
|
||||
function contextSourcePath(filePath, repoRoot) {
|
||||
if (!filePath) return null;
|
||||
const rel = path.relative(repoRoot, filePath);
|
||||
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {
|
||||
return rel.split(path.sep).join('/');
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function discoverRootsForPattern(repoRoot, rawPattern) {
|
||||
const pattern = normalizeWorkspacePattern(rawPattern);
|
||||
if (!pattern || pattern.startsWith('!')) return [];
|
||||
const segments = pattern.split('/').filter(Boolean);
|
||||
if (!segments.length) return [];
|
||||
const firstGlobIndex = segments.findIndex((segment) => segment.includes('*'));
|
||||
const literalPrefix = firstGlobIndex === -1 ? segments : segments.slice(0, firstGlobIndex);
|
||||
const base = path.join(repoRoot, ...literalPrefix);
|
||||
if (!fs.existsSync(base)) return [];
|
||||
if (segments.includes('**')) {
|
||||
const packageRoots = [];
|
||||
walkDirs(base, (dir) => {
|
||||
if (dir !== base && isCandidateProjectRoot(dir)) packageRoots.push(dir);
|
||||
});
|
||||
if (packageRoots.length) return packageRoots;
|
||||
return directChildDirs(base);
|
||||
}
|
||||
return expandSimplePattern(repoRoot, segments);
|
||||
}
|
||||
|
||||
function expandSimplePattern(repoRoot, patternSegments, index = 0, current = repoRoot) {
|
||||
if (index >= patternSegments.length) return fs.existsSync(current) ? [current] : [];
|
||||
const segment = patternSegments[index];
|
||||
if (!segment.includes('*')) {
|
||||
return expandSimplePattern(repoRoot, patternSegments, index + 1, path.join(current, segment));
|
||||
}
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(current, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const roots = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
||||
if (!segmentMatches(segment, entry.name)) continue;
|
||||
roots.push(...expandSimplePattern(repoRoot, patternSegments, index + 1, path.join(current, entry.name)));
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
function directChildDirs(dir) {
|
||||
try {
|
||||
return fs.readdirSync(dir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() && !isIgnoredWorkspaceDiscoveryDir(entry.name))
|
||||
.map((entry) => path.join(dir, entry.name));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function walkDirs(root, visit) {
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(root, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
||||
const dir = path.join(root, entry.name);
|
||||
visit(dir);
|
||||
walkDirs(dir, visit);
|
||||
}
|
||||
}
|
||||
|
||||
function isCandidateProjectRoot(dir) {
|
||||
return !!(
|
||||
fs.existsSync(path.join(dir, 'package.json'))
|
||||
|| firstExisting(dir, [...PRODUCT_NAMES, ...DESIGN_NAMES])
|
||||
|| fs.existsSync(path.join(dir, 'src'))
|
||||
|| fs.existsSync(path.join(dir, 'app'))
|
||||
|| fs.existsSync(path.join(dir, 'pages'))
|
||||
|| fs.existsSync(path.join(dir, 'public'))
|
||||
);
|
||||
}
|
||||
|
||||
function isIgnoredWorkspaceDiscoveryDir(name) {
|
||||
return name.startsWith('.') || WORKSPACE_DISCOVERY_IGNORED_DIRS.has(name);
|
||||
}
|
||||
|
||||
function findTargetExample(repoRoot, projectRoot) {
|
||||
const examples = [
|
||||
'src/App.jsx',
|
||||
'src/App.tsx',
|
||||
'src/main.jsx',
|
||||
'src/main.tsx',
|
||||
'src/index.jsx',
|
||||
'src/index.ts',
|
||||
'app/page.tsx',
|
||||
'pages/index.tsx',
|
||||
'public/index.html',
|
||||
];
|
||||
for (const rel of examples) {
|
||||
const abs = path.join(projectRoot, rel);
|
||||
if (fs.existsSync(abs)) return path.relative(repoRoot, abs).split(path.sep).join('/');
|
||||
}
|
||||
return path.relative(repoRoot, projectRoot).split(path.sep).join('/');
|
||||
}
|
||||
|
||||
function resolveWorkspaceProjectRoot(repoRoot, targetDir) {
|
||||
const rel = path.relative(repoRoot, targetDir);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return repoRoot;
|
||||
const relSegments = rel.split(path.sep).filter(Boolean);
|
||||
const patterns = readWorkspacePatterns(repoRoot);
|
||||
const excluded = isExcludedByWorkspacePattern(relSegments, patterns);
|
||||
if (!excluded) {
|
||||
for (const pattern of patterns) {
|
||||
const projectRoot = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern);
|
||||
if (projectRoot) return projectRoot;
|
||||
}
|
||||
}
|
||||
if (excluded) return repoRoot;
|
||||
if (
|
||||
relSegments.length >= 2
|
||||
&& MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0])
|
||||
) {
|
||||
return path.join(repoRoot, relSegments[0], relSegments[1]);
|
||||
}
|
||||
const nearest = nearestProjectLikeRoot(repoRoot, targetDir);
|
||||
if (nearest) return nearest;
|
||||
return repoRoot;
|
||||
}
|
||||
|
||||
function isExcludedByWorkspacePattern(relSegments, patterns) {
|
||||
return patterns.some((rawPattern) => {
|
||||
const pattern = normalizeWorkspacePattern(rawPattern);
|
||||
if (!pattern.startsWith('!')) return false;
|
||||
return workspacePatternMatchesRel(pattern.slice(1), relSegments);
|
||||
});
|
||||
}
|
||||
|
||||
function nearestProjectLikeRoot(repoRoot, targetDir) {
|
||||
let dir = path.resolve(targetDir);
|
||||
const stop = path.resolve(repoRoot);
|
||||
while (dir && dir !== stop) {
|
||||
if (
|
||||
firstExisting(dir, [...PRODUCT_NAMES, ...DESIGN_NAMES])
|
||||
|| fs.existsSync(path.join(dir, 'package.json'))
|
||||
) {
|
||||
return dir;
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function nearestPackageRootBetween(repoRoot, targetDir, stopDir) {
|
||||
let dir = path.resolve(targetDir);
|
||||
const stop = path.resolve(stopDir || repoRoot);
|
||||
const root = path.resolve(repoRoot);
|
||||
while (dir && dir !== stop && isPathInsideOrEqual(dir, root)) {
|
||||
if (fs.existsSync(path.join(dir, 'package.json'))) return dir;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPathInsideOrEqual(candidate, root) {
|
||||
return path.resolve(candidate) === path.resolve(root) || isPathInside(candidate, root);
|
||||
}
|
||||
|
||||
function workspacePatternMatchesRel(pattern, relSegments) {
|
||||
const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return false;
|
||||
if (patternSegments.includes('**')) {
|
||||
const firstGlobIndex = patternSegments.findIndex((segment) => segment.includes('*'));
|
||||
const literalPrefix = firstGlobIndex === -1
|
||||
? patternSegments
|
||||
: patternSegments.slice(0, firstGlobIndex);
|
||||
if (relSegments.length < literalPrefix.length + 1) return false;
|
||||
for (let i = 0; i < literalPrefix.length; i++) {
|
||||
if (!segmentMatches(literalPrefix[i], relSegments[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (relSegments.length < patternSegments.length) return false;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function readWorkspacePatterns(repoRoot) {
|
||||
return [
|
||||
...readPackageWorkspaces(repoRoot),
|
||||
...readPnpmWorkspaces(repoRoot),
|
||||
...readLernaWorkspaces(repoRoot),
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
function readPackageWorkspaces(repoRoot) {
|
||||
const pkg = readJson(path.join(repoRoot, 'package.json'));
|
||||
const workspaces = pkg?.workspaces;
|
||||
if (Array.isArray(workspaces)) return workspaces;
|
||||
if (Array.isArray(workspaces?.packages)) return workspaces.packages;
|
||||
return [];
|
||||
}
|
||||
|
||||
function readLernaWorkspaces(repoRoot) {
|
||||
const lerna = readJson(path.join(repoRoot, 'lerna.json'));
|
||||
return Array.isArray(lerna?.packages) ? lerna.packages : [];
|
||||
}
|
||||
|
||||
function readPnpmWorkspaces(repoRoot) {
|
||||
try {
|
||||
const body = fs.readFileSync(path.join(repoRoot, 'pnpm-workspace.yaml'), 'utf-8');
|
||||
const patterns = [];
|
||||
let inPackages = false;
|
||||
for (const line of body.split(/\r?\n/)) {
|
||||
const trimmed = stripYamlInlineComment(line).trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const flowMatch = trimmed.match(/^packages:\s*\[(.*)\]\s*$/);
|
||||
if (flowMatch) {
|
||||
patterns.push(...parseYamlFlowList(flowMatch[1]));
|
||||
inPackages = false;
|
||||
continue;
|
||||
}
|
||||
if (/^packages:\s*$/.test(trimmed)) {
|
||||
inPackages = true;
|
||||
continue;
|
||||
}
|
||||
if (inPackages && /^[A-Za-z0-9_-]+:\s*/.test(trimmed)) break;
|
||||
if (inPackages) {
|
||||
const match = trimmed.match(/^-\s*(.+)$/);
|
||||
if (match) patterns.push(unquoteYamlValue(match[1]));
|
||||
}
|
||||
}
|
||||
return patterns;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function stripYamlInlineComment(line) {
|
||||
let quote = null;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
if ((ch === '"' || ch === "'") && line[i - 1] !== '\\') {
|
||||
quote = quote === ch ? null : quote || ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === '#' && !quote) return line.slice(0, i);
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
function parseYamlFlowList(body) {
|
||||
const items = [];
|
||||
let quote = null;
|
||||
let current = '';
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
const ch = body[i];
|
||||
if ((ch === '"' || ch === "'") && body[i - 1] !== '\\') {
|
||||
quote = quote === ch ? null : quote || ch;
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === ',' && !quote) {
|
||||
const value = unquoteYamlValue(current);
|
||||
if (value) items.push(value);
|
||||
current = '';
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
}
|
||||
const value = unquoteYamlValue(current);
|
||||
if (value) items.push(value);
|
||||
return items;
|
||||
}
|
||||
|
||||
function unquoteYamlValue(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '');
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function projectRootFromWorkspacePattern(repoRoot, relSegments, rawPattern) {
|
||||
const pattern = normalizeWorkspacePattern(rawPattern);
|
||||
if (!pattern || pattern.startsWith('!')) return null;
|
||||
const patternSegments = pattern.split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return null;
|
||||
if (patternSegments.includes('**')) {
|
||||
return projectRootFromDoubleStarPattern(repoRoot, relSegments, patternSegments);
|
||||
}
|
||||
if (relSegments.length < patternSegments.length) return null;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return null;
|
||||
}
|
||||
return path.join(repoRoot, ...relSegments.slice(0, patternSegments.length));
|
||||
}
|
||||
|
||||
function projectRootFromDoubleStarPattern(repoRoot, relSegments, patternSegments) {
|
||||
const firstGlobIndex = patternSegments.findIndex((segment) => segment.includes('*'));
|
||||
const literalPrefix = firstGlobIndex === -1
|
||||
? patternSegments
|
||||
: patternSegments.slice(0, firstGlobIndex);
|
||||
if (relSegments.length < literalPrefix.length + 1) return null;
|
||||
for (let i = 0; i < literalPrefix.length; i++) {
|
||||
if (!segmentMatches(literalPrefix[i], relSegments[i])) return null;
|
||||
}
|
||||
const prefixDir = path.join(repoRoot, ...literalPrefix);
|
||||
const targetDir = path.join(repoRoot, ...relSegments);
|
||||
const packageRoot = nearestPackageRootBetween(repoRoot, targetDir, prefixDir);
|
||||
if (packageRoot) return packageRoot;
|
||||
return path.join(repoRoot, ...relSegments.slice(0, literalPrefix.length + 1));
|
||||
}
|
||||
|
||||
function normalizeWorkspacePattern(pattern) {
|
||||
return String(pattern || '')
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '')
|
||||
.replace(/^\.\//, '')
|
||||
.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function segmentMatches(patternSegment, relSegment) {
|
||||
if (patternSegment === '*') return true;
|
||||
if (!patternSegment.includes('*')) return patternSegment === relSegment;
|
||||
const re = new RegExp(`^${escapeRegExp(patternSegment).replace(/\\\*/g, '[^/]*')}$`);
|
||||
return re.test(relSegment);
|
||||
}
|
||||
|
||||
function firstExisting(dir, names) {
|
||||
for (const name of names) {
|
||||
const abs = path.join(dir, name);
|
||||
@@ -89,6 +664,10 @@ function safeRead(p) {
|
||||
}
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the register (`brand` or `product`) out of PRODUCT.md by looking
|
||||
* for a `## Register` section and reading the first non-empty line that
|
||||
@@ -233,7 +812,24 @@ async function computeUpdateDirective(now = Date.now()) {
|
||||
}
|
||||
|
||||
async function cli() {
|
||||
const ctx = loadContext(process.cwd());
|
||||
let cliOptions;
|
||||
try {
|
||||
cliOptions = parseCliOptions(process.argv.slice(2));
|
||||
} catch (err) {
|
||||
if (err?.name === 'TargetArgError') {
|
||||
process.stderr.write(`${err.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const targetProvided = hasTargetOption(cliOptions);
|
||||
const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null;
|
||||
const selection = resolveTargetSelection(process.cwd(), cliOptions);
|
||||
if (selection) {
|
||||
process.stdout.write(buildTargetSelectionDirective(selection) + '\n');
|
||||
process.exit(0);
|
||||
}
|
||||
const ctx = loadContext(process.cwd(), cliOptions);
|
||||
const updateDirective = await computeUpdateDirective();
|
||||
|
||||
if (!ctx.hasProduct) {
|
||||
@@ -244,6 +840,10 @@ async function cli() {
|
||||
'Stop the current task, load reference/init.md, and follow its ' +
|
||||
'instructions to write PRODUCT.md before resuming.',
|
||||
];
|
||||
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
|
||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||
parts.push(buildMissingTargetDirective());
|
||||
}
|
||||
if (updateDirective) parts.push(updateDirective);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
process.exit(0);
|
||||
@@ -252,6 +852,10 @@ async function cli() {
|
||||
if (ctx.hasDesign) {
|
||||
parts.push(`# DESIGN.md\n\n${ctx.design.trim()}`);
|
||||
}
|
||||
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
|
||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||
parts.push(buildMissingTargetDirective());
|
||||
}
|
||||
const register = extractRegister(ctx.product);
|
||||
const next = register
|
||||
? `NEXT STEP: This project's register is \`${register}\`. You MUST now read \`reference/${register}.md\` before producing any design output.`
|
||||
@@ -261,6 +865,60 @@ async function cli() {
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
}
|
||||
|
||||
function parseCliOptions(args) {
|
||||
return parseTargetOptions(args, { strict: true });
|
||||
}
|
||||
|
||||
function hasTargetOption(options) {
|
||||
return !!(options && typeof options.targetPath === 'string' && options.targetPath.trim());
|
||||
}
|
||||
|
||||
function pathExistsForTarget(cwd, targetPath) {
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
return fs.existsSync(abs);
|
||||
}
|
||||
|
||||
function buildResolvedContextDirective(ctx, options, { targetExists = null } = {}) {
|
||||
const targetPath = hasTargetOption(options) ? options.targetPath : null;
|
||||
return `RESOLVED_CONTEXT:\n${JSON.stringify({
|
||||
targetPath,
|
||||
...(targetPath ? { targetExists } : {}),
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
productPath: ctx.productPath,
|
||||
designPath: ctx.designPath,
|
||||
}, null, 2)}`;
|
||||
}
|
||||
|
||||
function shouldWarnMissingTarget(ctx, targetProvided, targetExists = null) {
|
||||
if (ctx.isMonorepo && targetProvided && targetExists === false) return true;
|
||||
return !!(
|
||||
ctx.isMonorepo
|
||||
&& (!targetProvided || targetExists === false)
|
||||
&& ctx.projectRoot
|
||||
&& ctx.repoRoot
|
||||
&& path.resolve(ctx.projectRoot) === path.resolve(ctx.repoRoot)
|
||||
);
|
||||
}
|
||||
|
||||
function buildMissingTargetDirective() {
|
||||
const script = process.argv[1] || 'context.mjs';
|
||||
return (
|
||||
'MONOREPO_TARGET_REQUIRED: This is a monorepo and context.mjs ran without --target. ' +
|
||||
'If the user named a file, route, or child app, do not answer from this output. ' +
|
||||
`Rerun \`node ${script} --target <path>\` and answer from that run's RESOLVED_CONTEXT fields.`
|
||||
);
|
||||
}
|
||||
|
||||
function buildTargetSelectionDirective(selection) {
|
||||
return (
|
||||
`TARGET_SELECTION_REQUIRED:\n${JSON.stringify(selection, null, 2)}\n\n` +
|
||||
'Show each app with its productStatus/productPath and designStatus/designPath so the user can see child overrides, inherited root files, fallback files, or missing files before choosing. ' +
|
||||
'Ask the user which app Impeccable should use, then rerun Impeccable helper commands from that child app cwd using this same scripts directory. ' +
|
||||
'Use `--target <path>` only as a fallback when changing cwd is not possible, or when the user explicitly named a file/path.'
|
||||
);
|
||||
}
|
||||
|
||||
// Run cli() only when this module is the entry point. Compare realpaths
|
||||
// rather than endsWith(): a loose suffix match also fires for unrelated
|
||||
// scripts like `load-context.mjs`, and realpath tolerates symlinked
|
||||
|
||||
@@ -1,50 +1,52 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { resolveProjectRoot } from '../context.mjs';
|
||||
|
||||
export const IMPECCABLE_DIR = '.impeccable';
|
||||
export const LIVE_DIR = 'live';
|
||||
export const CRITIQUE_DIR = 'critique';
|
||||
|
||||
export function getImpeccableDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, IMPECCABLE_DIR);
|
||||
export function getImpeccableDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), IMPECCABLE_DIR);
|
||||
}
|
||||
|
||||
export function getDesignSidecarPath(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), 'design.json');
|
||||
export function getDesignSidecarPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), 'design.json');
|
||||
}
|
||||
|
||||
export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd) {
|
||||
export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd, options = {}) {
|
||||
const projectRoot = resolveProjectRoot(cwd, options);
|
||||
const candidates = [
|
||||
getDesignSidecarPath(cwd),
|
||||
path.join(cwd, 'DESIGN.json'),
|
||||
getDesignSidecarPath(cwd, options),
|
||||
path.join(projectRoot, 'DESIGN.json'),
|
||||
];
|
||||
const contextLegacy = path.join(contextDir, 'DESIGN.json');
|
||||
if (!candidates.includes(contextLegacy)) candidates.push(contextLegacy);
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) {
|
||||
return firstExisting(getDesignSidecarCandidates(cwd, contextDir));
|
||||
export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd, options = {}) {
|
||||
return firstExisting(getDesignSidecarCandidates(cwd, contextDir, options));
|
||||
}
|
||||
|
||||
export function getLiveDir(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), LIVE_DIR);
|
||||
export function getLiveDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), LIVE_DIR);
|
||||
}
|
||||
|
||||
export function getLiveConfigPath(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'config.json');
|
||||
export function getLiveConfigPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'config.json');
|
||||
}
|
||||
|
||||
export function getLegacyLiveConfigPath(scriptsDir) {
|
||||
return path.join(scriptsDir, 'config.json');
|
||||
}
|
||||
|
||||
export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env } = {}) {
|
||||
export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env, targetPath } = {}) {
|
||||
if (env.IMPECCABLE_LIVE_CONFIG && env.IMPECCABLE_LIVE_CONFIG.trim()) {
|
||||
const configured = env.IMPECCABLE_LIVE_CONFIG.trim();
|
||||
return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured);
|
||||
}
|
||||
const primary = getLiveConfigPath(cwd);
|
||||
const primary = getLiveConfigPath(cwd, { targetPath });
|
||||
if (fs.existsSync(primary)) return primary;
|
||||
if (scriptsDir) {
|
||||
const legacy = getLegacyLiveConfigPath(scriptsDir);
|
||||
@@ -53,16 +55,16 @@ export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = p
|
||||
return primary;
|
||||
}
|
||||
|
||||
export function getLiveServerPath(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'server.json');
|
||||
export function getLiveServerPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'server.json');
|
||||
}
|
||||
|
||||
export function getLegacyLiveServerPath(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live.json');
|
||||
export function getLegacyLiveServerPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live.json');
|
||||
}
|
||||
|
||||
export function readLiveServerInfo(cwd = process.cwd()) {
|
||||
for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) {
|
||||
export function readLiveServerInfo(cwd = process.cwd(), options = {}) {
|
||||
for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) {
|
||||
try {
|
||||
const info = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
if (info && typeof info.pid === 'number' && !isLiveServerPidReachable(info.pid)) {
|
||||
@@ -88,37 +90,37 @@ export function isLiveServerPidReachable(pid) {
|
||||
}
|
||||
}
|
||||
|
||||
export function writeLiveServerInfo(cwd = process.cwd(), info) {
|
||||
const filePath = getLiveServerPath(cwd);
|
||||
export function writeLiveServerInfo(cwd = process.cwd(), info, options = {}) {
|
||||
const filePath = getLiveServerPath(cwd, options);
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(info));
|
||||
return filePath;
|
||||
}
|
||||
|
||||
export function removeLiveServerInfo(cwd = process.cwd()) {
|
||||
for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) {
|
||||
export function removeLiveServerInfo(cwd = process.cwd(), options = {}) {
|
||||
for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) {
|
||||
try { fs.unlinkSync(filePath); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
export function getLiveSessionsDir(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'sessions');
|
||||
export function getLiveSessionsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'sessions');
|
||||
}
|
||||
|
||||
export function getLegacyLiveSessionsDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live', 'sessions');
|
||||
export function getLegacyLiveSessionsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'sessions');
|
||||
}
|
||||
|
||||
export function getLiveAnnotationsDir(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'annotations');
|
||||
export function getLiveAnnotationsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'annotations');
|
||||
}
|
||||
|
||||
export function getCritiqueDir(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), CRITIQUE_DIR);
|
||||
export function getCritiqueDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), CRITIQUE_DIR);
|
||||
}
|
||||
|
||||
export function getLegacyLiveAnnotationsDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live', 'annotations');
|
||||
export function getLegacyLiveAnnotationsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'annotations');
|
||||
}
|
||||
|
||||
function firstExisting(paths) {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
class TargetArgError extends Error {
|
||||
constructor(message, code) {
|
||||
super(message);
|
||||
this.name = 'TargetArgError';
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseTargetPath(args = [], { strict = false } = {}) {
|
||||
let targetPath = null;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = String(args[i]);
|
||||
if (arg === '--target' || arg === '-t') {
|
||||
const next = args[i + 1];
|
||||
if (next && !String(next).startsWith('-')) {
|
||||
targetPath = String(next);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (strict) {
|
||||
throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith('--target=')) {
|
||||
const value = arg.slice('--target='.length);
|
||||
if (value) {
|
||||
targetPath = value;
|
||||
continue;
|
||||
}
|
||||
if (strict) {
|
||||
throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING');
|
||||
}
|
||||
}
|
||||
}
|
||||
return targetPath;
|
||||
}
|
||||
|
||||
export function parseTargetOptions(args = [], options = {}) {
|
||||
const targetPath = parseTargetPath(args, options);
|
||||
return targetPath ? { targetPath } : {};
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import path from 'node:path';
|
||||
import net from 'node:net';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseDesignMd } from './lib/design-parser.mjs';
|
||||
import { resolveContextDir } from './context.mjs';
|
||||
import { loadContext } from './context.mjs';
|
||||
import {
|
||||
assembleLiveBrowserScript,
|
||||
assertLiveBrowserScriptParts,
|
||||
@@ -55,7 +55,11 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
// PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
|
||||
// DESIGN sidecar is project-local at .impeccable/design.json, with legacy
|
||||
// DESIGN.json fallback for existing projects.
|
||||
const CONTEXT_DIR = resolveContextDir(process.cwd());
|
||||
const PROJECT_CONTEXT = loadContext(process.cwd());
|
||||
const CONTEXT_DIR = PROJECT_CONTEXT.contextDir;
|
||||
const DESIGN_MD_PATH = PROJECT_CONTEXT.designPath
|
||||
? path.resolve(process.cwd(), PROJECT_CONTEXT.designPath)
|
||||
: null;
|
||||
const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway
|
||||
const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s
|
||||
|
||||
@@ -371,10 +375,7 @@ function hasProjectContext() {
|
||||
// PRODUCT.md carries brand voice / anti-references — that's what determines
|
||||
// whether variants are brand-aware. DESIGN.md (visual tokens) is a separate
|
||||
// concern, surfaced by the design panel's own empty state.
|
||||
try {
|
||||
fs.accessSync(path.join(CONTEXT_DIR, 'PRODUCT.md'), fs.constants.R_OK);
|
||||
return true;
|
||||
} catch { return false; }
|
||||
return !!PROJECT_CONTEXT.hasProduct;
|
||||
}
|
||||
|
||||
function statOrNull(filePath) {
|
||||
@@ -549,8 +550,8 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
const token = url.searchParams.get('token');
|
||||
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
|
||||
|
||||
const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md');
|
||||
const jsonPath = resolveDesignSidecarPath(process.cwd(), CONTEXT_DIR) || getDesignSidecarPath(process.cwd());
|
||||
const mdPath = DESIGN_MD_PATH;
|
||||
const jsonPath = resolveDesignSidecarPath(process.cwd(), PROJECT_CONTEXT.designContextDir || CONTEXT_DIR) || getDesignSidecarPath(process.cwd());
|
||||
const mdStat = statOrNull(mdPath);
|
||||
const jsonStat = statOrNull(jsonPath);
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import path from 'node:path';
|
||||
import { resolveProjectRoot } from './context.mjs';
|
||||
import { parseTargetPath } from './lib/target-args.mjs';
|
||||
|
||||
export function resolveLiveTarget(cwd = process.cwd(), args = []) {
|
||||
const originalCwd = path.resolve(cwd);
|
||||
let targetPath = null;
|
||||
try {
|
||||
targetPath = parseTargetPath(args, { strict: true });
|
||||
} catch (err) {
|
||||
if (err?.name === 'TargetArgError') {
|
||||
process.stderr.write(`${err.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const absoluteTargetPath = targetPath
|
||||
? path.isAbsolute(targetPath) ? targetPath : path.resolve(originalCwd, targetPath)
|
||||
: null;
|
||||
const projectRoot = targetPath
|
||||
? resolveProjectRoot(originalCwd, { targetPath: absoluteTargetPath })
|
||||
: originalCwd;
|
||||
return {
|
||||
originalCwd,
|
||||
projectRoot,
|
||||
targetPath,
|
||||
absoluteTargetPath,
|
||||
targetOptions: absoluteTargetPath ? { targetPath: absoluteTargetPath } : {},
|
||||
};
|
||||
}
|
||||
@@ -21,14 +21,16 @@ import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadContext } from './context.mjs';
|
||||
import { loadContext, resolveTargetSelection } from './context.mjs';
|
||||
import { resolveFiles } from './live-inject.mjs';
|
||||
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
|
||||
import { resolveLiveTarget } from './live-target.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
async function liveCli() {
|
||||
const args = process.argv.slice(2);
|
||||
const liveTarget = resolveLiveTarget(process.cwd(), args);
|
||||
|
||||
if (args.includes('--help') || args.includes('-h')) {
|
||||
console.log(`Usage: node live.mjs
|
||||
@@ -38,37 +40,78 @@ Prepare everything for live variant mode in a single command:
|
||||
- Starts (or reuses) the live server in the background
|
||||
- Injects the browser script tag
|
||||
- Reads PRODUCT.md / DESIGN.md for project context
|
||||
- In monorepos, choose a child app first; --target <path> is the fallback/manual path
|
||||
|
||||
On success, prints a JSON blob with:
|
||||
{ ok, serverPort, serverToken, pageFile, hasContext, context }
|
||||
{ ok, serverPort, serverToken, pageFiles, projectRoot, repoRoot, targetPath, productPath, designPath }
|
||||
|
||||
On target_selection_required, prints:
|
||||
{ ok: false, error: "target_selection_required", targetCandidates }
|
||||
|
||||
On config_missing, prints:
|
||||
{ ok: false, error: "config_missing", configPath, hint }
|
||||
|
||||
The agent should then:
|
||||
1. If config_missing, create the config and re-run this script
|
||||
2. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort)
|
||||
3. Enter the poll loop: node live-poll.mjs`);
|
||||
1. If target_selection_required, ask which app to use and rerun from that child cwd
|
||||
2. If config_missing, create the config and re-run this script
|
||||
3. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort)
|
||||
4. Enter the poll loop: node live-poll.mjs`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const targetSelection = resolveTargetSelection(liveTarget.originalCwd, liveTarget.targetOptions);
|
||||
if (targetSelection) {
|
||||
console.log(JSON.stringify({
|
||||
ok: false,
|
||||
error: 'target_selection_required',
|
||||
...targetSelection,
|
||||
hint: 'Ask the user which app Impeccable should use, then rerun live from that child app cwd. Use --target <path> only as a fallback or explicit path diagnostic.',
|
||||
}, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const ctx = loadContext(liveTarget.originalCwd, liveTarget.targetOptions);
|
||||
const activeCwd = ctx.projectRoot;
|
||||
const outputTargetPath = liveTarget.targetPath || null;
|
||||
|
||||
const missingContext = missingLiveContext(ctx);
|
||||
if (missingContext.length > 0) {
|
||||
console.log(JSON.stringify({
|
||||
ok: false,
|
||||
error: 'context_missing',
|
||||
missing: missingContext,
|
||||
nextCommand: missingContext.includes('PRODUCT.md') ? 'init' : 'document',
|
||||
targetPath: outputTargetPath,
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
productPath: ctx.productPath,
|
||||
designPath: ctx.designPath,
|
||||
}, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 1. Check config (fail fast if missing — no point starting anything else)
|
||||
const checkOut = runScript('live-inject.mjs', ['--check']);
|
||||
const checkOut = runScript('live-inject.mjs', ['--check'], { cwd: activeCwd });
|
||||
const checkResult = safeParse(checkOut);
|
||||
if (!checkResult || !checkResult.ok) {
|
||||
console.log(JSON.stringify(checkResult || { ok: false, error: 'check_failed', raw: checkOut }));
|
||||
console.log(JSON.stringify({
|
||||
...(checkResult || { ok: false, error: 'check_failed', raw: checkOut }),
|
||||
targetPath: outputTargetPath,
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
}));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 2. Start server (or reuse existing)
|
||||
const serverInfo = ensureServerRunning();
|
||||
const serverInfo = ensureServerRunning(activeCwd);
|
||||
if (!serverInfo) {
|
||||
console.log(JSON.stringify({ ok: false, error: 'server_start_failed' }));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 3. Inject the script tag at the current port
|
||||
const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)]);
|
||||
const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)], { cwd: activeCwd });
|
||||
const injectResult = safeParse(injectOut);
|
||||
if (!injectResult || !injectResult.ok) {
|
||||
console.log(JSON.stringify({
|
||||
@@ -80,22 +123,23 @@ The agent should then:
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 4. Load PRODUCT.md + DESIGN.md context.
|
||||
const ctx = loadContext(process.cwd());
|
||||
|
||||
// 5. Compute drift-heal: compare resolved inject targets against the
|
||||
// 4. 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);
|
||||
const resolvedFiles = resolveFiles(activeCwd, checkResult.config);
|
||||
const drift = scanForDrift(activeCwd, resolvedFiles, checkResult.config);
|
||||
|
||||
// 6. Emit everything the agent needs
|
||||
// 5. Emit everything the agent needs
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
serverPort: serverInfo.port,
|
||||
serverToken: serverInfo.token,
|
||||
pageFiles: resolvedFiles,
|
||||
liveConfigPath: checkResult.path,
|
||||
configDrift: drift,
|
||||
targetPath: outputTargetPath,
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
hasProduct: ctx.hasProduct,
|
||||
product: ctx.product,
|
||||
productPath: ctx.productPath,
|
||||
@@ -105,6 +149,13 @@ The agent should then:
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
function missingLiveContext(ctx) {
|
||||
const missing = [];
|
||||
if (!ctx.hasProduct) missing.push('PRODUCT.md');
|
||||
if (!ctx.hasDesign) missing.push('DESIGN.md');
|
||||
return missing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drift-heal scan. Walks the project for HTML files under common
|
||||
* page-source directories (public/, src/, app/, pages/) and reports any
|
||||
@@ -201,11 +252,11 @@ function globToRegex(pattern) {
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function runScript(name, args) {
|
||||
function runScript(name, args, options = {}) {
|
||||
const scriptPath = path.join(__dirname, name);
|
||||
const cmd = `node "${scriptPath}" ${args.map(a => `"${a}"`).join(' ')}`;
|
||||
try {
|
||||
return execSync(cmd, { encoding: 'utf-8', cwd: process.cwd(), timeout: 15_000 });
|
||||
return execSync(cmd, { encoding: 'utf-8', cwd: options.cwd || process.cwd(), timeout: 15_000 });
|
||||
} catch (err) {
|
||||
// execSync throws on non-zero exit; return stdout if any
|
||||
return err.stdout || err.message || '';
|
||||
@@ -219,10 +270,10 @@ function safeParse(out) {
|
||||
/**
|
||||
* Return { pid, port, token } for the running live server, starting one if needed.
|
||||
*/
|
||||
function ensureServerRunning() {
|
||||
function ensureServerRunning(cwd = process.cwd()) {
|
||||
// Try to reuse an existing server
|
||||
try {
|
||||
const existing = readLiveServerInfo(process.cwd())?.info;
|
||||
const existing = readLiveServerInfo(cwd)?.info;
|
||||
if (existing && existing.pid) {
|
||||
try {
|
||||
process.kill(existing.pid, 0); // throws if dead
|
||||
@@ -232,7 +283,7 @@ function ensureServerRunning() {
|
||||
} catch { /* no PID file */ }
|
||||
|
||||
// Start a new server
|
||||
const out = runScript('live-server.mjs', ['--background']);
|
||||
const out = runScript('live-server.mjs', ['--background'], { cwd });
|
||||
return safeParse(out);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ Designs and iterates production-grade frontend interfaces. Real working code, co
|
||||
|
||||
You MUST do these steps before proceeding:
|
||||
|
||||
1. Run `node .claude/skills/impeccable/scripts/context.mjs` once per session. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task.
|
||||
1. Run `node .claude/skills/impeccable/scripts/context.mjs` once per session. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .claude/skills/impeccable/scripts/context.mjs --target <path>` instead. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task.
|
||||
2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read `reference/<command>.md` next. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects.
|
||||
3. Familiarize yourself with any existing design system, conventions, and components in the code. Read at least one project file (CSS / tokens / theme / a representative component or page). **Required even when you've loaded a sub-command reference in step 2.** Don't reinvent the wheel; use what's there when it works, branch out when the UX wins.
|
||||
4. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md.
|
||||
|
||||
@@ -8,7 +8,7 @@ A running dev server with hot module replacement (Vite, Next.js, Bun, etc.), OR
|
||||
|
||||
Execute in order. No step skipped, no step reordered.
|
||||
|
||||
1. `live.mjs`: boot.
|
||||
1. `live.mjs`: boot. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .claude/skills/impeccable/scripts/live.mjs --target <path>` instead; then run the rest of this live session from the returned `projectRoot`.
|
||||
2. Open the app URL that serves `pageFile` (infer from `package.json`, docs, terminal output, or an open tab). Never use `serverPort`; it's the helper, not the app. **Cursor:** `browser_navigate` to that URL before polling; do not skip. **Other harnesses:** use the available browser tool; if the URL is uncertain, ask the user once.
|
||||
3. Poll loop with the default long timeout (600000 ms). After every event or `--reply`, run `live-poll.mjs` again immediately. Never pass a short `--timeout=`.
|
||||
|
||||
|
||||
@@ -5,11 +5,12 @@
|
||||
* init flow.
|
||||
*
|
||||
* Path resolution (first match wins):
|
||||
* 1. cwd, if PRODUCT.md or DESIGN.md is there
|
||||
* 2. .agents/context/ then docs/
|
||||
* 3. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user
|
||||
* 1. Active project root, if PRODUCT.md or DESIGN.md is there
|
||||
* 2. Active project .agents/context/ then docs/
|
||||
* 3. Monorepo root context, using the same order, as a per-file fallback
|
||||
* 4. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user
|
||||
* escape hatch, only consulted when defaults are empty
|
||||
* 4. cwd as a "nothing found" default
|
||||
* 5. Active project root as a "nothing found" default
|
||||
*
|
||||
* `resolveContextDir()` and `loadContext()` are also exported for the
|
||||
* server-side scripts (live.mjs, live-server.mjs) that need the structured
|
||||
@@ -19,10 +20,25 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseTargetOptions } from './lib/target-args.mjs';
|
||||
|
||||
const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md'];
|
||||
const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md'];
|
||||
const FALLBACK_DIRS = ['.agents/context', 'docs'];
|
||||
const MONOREPO_MARKER_FILES = ['pnpm-workspace.yaml', 'turbo.json', 'nx.json', 'lerna.json'];
|
||||
const MONOREPO_FALLBACK_PROJECT_DIRS = ['apps', 'packages'];
|
||||
const WORKSPACE_DISCOVERY_IGNORED_DIRS = new Set([
|
||||
'node_modules',
|
||||
'.git',
|
||||
'dist',
|
||||
'build',
|
||||
'.next',
|
||||
'.nuxt',
|
||||
'.svelte-kit',
|
||||
'.turbo',
|
||||
'.cache',
|
||||
'coverage',
|
||||
]);
|
||||
|
||||
// ─── Update check ──────────────────────────────────────────────────────────
|
||||
// Piggyback a lightweight skill-version check on the once-per-session boot.
|
||||
@@ -38,41 +54,600 @@ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // throttle the network poll to o
|
||||
const RENOTIFY_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000; // don't re-surface the same version for a week
|
||||
const FETCH_TIMEOUT_MS = 1200;
|
||||
|
||||
export function resolveContextDir(cwd = process.cwd()) {
|
||||
if (firstExisting(cwd, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return cwd;
|
||||
}
|
||||
for (const rel of FALLBACK_DIRS) {
|
||||
const candidate = path.resolve(cwd, rel);
|
||||
if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
const envDir = process.env.IMPECCABLE_CONTEXT_DIR;
|
||||
if (envDir && envDir.trim()) {
|
||||
const trimmed = envDir.trim();
|
||||
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
||||
}
|
||||
return cwd;
|
||||
export function resolveContextDir(cwd = process.cwd(), options = {}) {
|
||||
return resolveContext(cwd, options).contextDir;
|
||||
}
|
||||
|
||||
export function loadContext(cwd = process.cwd()) {
|
||||
const contextDir = resolveContextDir(cwd);
|
||||
const productPath = firstExisting(contextDir, PRODUCT_NAMES);
|
||||
const designPath = firstExisting(contextDir, DESIGN_NAMES);
|
||||
export function loadContext(cwd = process.cwd(), options = {}) {
|
||||
const resolved = resolveContext(cwd, options);
|
||||
const absCwd = path.resolve(cwd);
|
||||
const productPath = resolved.productPath;
|
||||
const designPath = resolved.designPath;
|
||||
const product = productPath ? safeRead(productPath) : null;
|
||||
const design = designPath ? safeRead(designPath) : null;
|
||||
return {
|
||||
hasProduct: !!product,
|
||||
product,
|
||||
productPath: productPath ? path.relative(cwd, productPath) : null,
|
||||
productPath: productPath ? path.relative(absCwd, productPath) : null,
|
||||
hasDesign: !!design,
|
||||
design,
|
||||
designPath: designPath ? path.relative(cwd, designPath) : null,
|
||||
contextDir,
|
||||
designPath: designPath ? path.relative(absCwd, designPath) : null,
|
||||
contextDir: resolved.contextDir,
|
||||
productContextDir: productPath ? path.dirname(productPath) : null,
|
||||
designContextDir: designPath ? path.dirname(designPath) : null,
|
||||
projectRoot: resolved.projectRoot,
|
||||
repoRoot: resolved.repoRoot,
|
||||
isMonorepo: resolved.isMonorepo,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveContext(cwd = process.cwd(), options = {}) {
|
||||
const absCwd = path.resolve(cwd);
|
||||
const project = resolveProject(absCwd, options);
|
||||
const projectContextDir = resolveLocalContextDir(project.projectRoot);
|
||||
const rootContextDir = project.isMonorepo && project.repoRoot !== project.projectRoot
|
||||
? resolveLocalContextDir(project.repoRoot)
|
||||
: null;
|
||||
|
||||
let productPath =
|
||||
(projectContextDir ? firstExisting(projectContextDir, PRODUCT_NAMES) : null)
|
||||
|| (rootContextDir ? firstExisting(rootContextDir, PRODUCT_NAMES) : null);
|
||||
let designPath =
|
||||
(projectContextDir ? firstExisting(projectContextDir, DESIGN_NAMES) : null)
|
||||
|| (rootContextDir ? firstExisting(rootContextDir, DESIGN_NAMES) : null);
|
||||
|
||||
let envContextDir = null;
|
||||
if (!productPath && !designPath) {
|
||||
envContextDir = resolveEnvContextDir(absCwd);
|
||||
if (envContextDir) {
|
||||
productPath = firstExisting(envContextDir, PRODUCT_NAMES);
|
||||
designPath = firstExisting(envContextDir, DESIGN_NAMES);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
contextDir: productPath
|
||||
? path.dirname(productPath)
|
||||
: designPath
|
||||
? path.dirname(designPath)
|
||||
: envContextDir || project.projectRoot,
|
||||
productPath,
|
||||
designPath,
|
||||
projectRoot: project.projectRoot,
|
||||
repoRoot: project.repoRoot,
|
||||
isMonorepo: project.isMonorepo,
|
||||
targetDir: project.targetDir,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveProjectRoot(cwd = process.cwd(), options = {}) {
|
||||
return resolveProject(cwd, options).projectRoot;
|
||||
}
|
||||
|
||||
export function resolveTargetSelection(cwd = process.cwd(), options = {}) {
|
||||
if (hasTargetOption(options)) return null;
|
||||
const project = resolveProject(cwd);
|
||||
if (
|
||||
!project.isMonorepo
|
||||
|| !project.projectRoot
|
||||
|| !project.repoRoot
|
||||
|| path.resolve(project.projectRoot) !== path.resolve(project.repoRoot)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
targetPath: null,
|
||||
projectRoot: project.projectRoot,
|
||||
repoRoot: project.repoRoot,
|
||||
targetCandidates: discoverTargetCandidates(project.repoRoot),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveProject(cwd = process.cwd(), options = {}) {
|
||||
const absCwd = path.resolve(cwd);
|
||||
const targetDir = resolveTargetDir(absCwd, options);
|
||||
let repoRoot = findMonorepoRoot(targetDir);
|
||||
if (!repoRoot && targetDir !== absCwd) {
|
||||
const cwdRepoRoot = findMonorepoRoot(absCwd);
|
||||
if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {
|
||||
repoRoot = cwdRepoRoot;
|
||||
}
|
||||
}
|
||||
if (!repoRoot) {
|
||||
return {
|
||||
targetDir,
|
||||
projectRoot: absCwd,
|
||||
repoRoot: absCwd,
|
||||
isMonorepo: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
targetDir,
|
||||
projectRoot: resolveWorkspaceProjectRoot(repoRoot, targetDir) || repoRoot,
|
||||
repoRoot,
|
||||
isMonorepo: true,
|
||||
};
|
||||
}
|
||||
|
||||
function isPathInside(candidate, root) {
|
||||
const rel = path.relative(root, candidate);
|
||||
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
|
||||
}
|
||||
|
||||
function resolveLocalContextDir(root) {
|
||||
if (firstExisting(root, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return root;
|
||||
}
|
||||
for (const rel of FALLBACK_DIRS) {
|
||||
const candidate = path.resolve(root, rel);
|
||||
if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveEnvContextDir(cwd) {
|
||||
const envDir = process.env.IMPECCABLE_CONTEXT_DIR;
|
||||
if (!envDir || !envDir.trim()) return null;
|
||||
const trimmed = envDir.trim();
|
||||
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
||||
}
|
||||
|
||||
function resolveTargetDir(cwd, options = {}) {
|
||||
const targetPath = options && typeof options === 'object' ? options.targetPath : null;
|
||||
if (!targetPath || !String(targetPath).trim()) return cwd;
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
try {
|
||||
const stat = fs.statSync(abs);
|
||||
return stat.isDirectory() ? abs : path.dirname(abs);
|
||||
} catch {
|
||||
return path.extname(abs) ? path.dirname(abs) : abs;
|
||||
}
|
||||
}
|
||||
|
||||
function findMonorepoRoot(startDir) {
|
||||
let dir = path.resolve(startDir);
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
while (true) {
|
||||
if (dir === homeDir) return null;
|
||||
if (isMonorepoRoot(dir)) return dir;
|
||||
if (hasGitBoundary(dir)) return null;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function isMonorepoRoot(dir) {
|
||||
if (readWorkspacePatterns(dir).some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) return true;
|
||||
if (!MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(dir, file)))) return false;
|
||||
return hasFallbackWorkspaceChildren(dir);
|
||||
}
|
||||
|
||||
function hasGitBoundary(dir) {
|
||||
return fs.existsSync(path.join(dir, '.git'));
|
||||
}
|
||||
|
||||
function hasFallbackWorkspaceChildren(dir) {
|
||||
for (const name of MONOREPO_FALLBACK_PROJECT_DIRS) {
|
||||
const base = path.join(dir, name);
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(base, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (entries.some((entry) => entry.isDirectory() && !isIgnoredWorkspaceDiscoveryDir(entry.name))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function discoverTargetCandidates(repoRoot) {
|
||||
const roots = new Map();
|
||||
for (const pattern of readWorkspacePatterns(repoRoot)) {
|
||||
for (const root of discoverRootsForPattern(repoRoot, pattern)) {
|
||||
roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root);
|
||||
}
|
||||
}
|
||||
if (MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(repoRoot, file)))) {
|
||||
for (const name of MONOREPO_FALLBACK_PROJECT_DIRS) {
|
||||
const base = path.join(repoRoot, name);
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(base, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
||||
const root = path.join(base, entry.name);
|
||||
roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...roots.entries()]
|
||||
.filter(([rel]) => rel && !rel.startsWith('..'))
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([rel, root]) => {
|
||||
const targetExample = findTargetExample(repoRoot, root);
|
||||
return {
|
||||
name: path.basename(root),
|
||||
path: rel,
|
||||
targetExample,
|
||||
...resolveCandidateContextSummary(repoRoot, root, targetExample),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function resolveCandidateContextSummary(repoRoot, projectRoot, targetPath) {
|
||||
const ctx = resolveContext(repoRoot, { targetPath });
|
||||
return {
|
||||
productStatus: contextSourceStatus(ctx.productPath, repoRoot, projectRoot),
|
||||
productPath: contextSourcePath(ctx.productPath, repoRoot),
|
||||
designStatus: contextSourceStatus(ctx.designPath, repoRoot, projectRoot),
|
||||
designPath: contextSourcePath(ctx.designPath, repoRoot),
|
||||
};
|
||||
}
|
||||
|
||||
function contextSourceStatus(filePath, repoRoot, projectRoot) {
|
||||
if (!filePath) return 'missing';
|
||||
const absPath = path.resolve(filePath);
|
||||
const absProjectRoot = path.resolve(projectRoot);
|
||||
const absRepoRoot = path.resolve(repoRoot);
|
||||
if (isPathInsideOrEqual(absPath, absProjectRoot)) {
|
||||
return path.dirname(absPath) === absProjectRoot ? 'child' : 'fallback';
|
||||
}
|
||||
if (absProjectRoot !== absRepoRoot && isPathInsideOrEqual(absPath, absRepoRoot)) {
|
||||
return 'inherited';
|
||||
}
|
||||
return 'fallback';
|
||||
}
|
||||
|
||||
function contextSourcePath(filePath, repoRoot) {
|
||||
if (!filePath) return null;
|
||||
const rel = path.relative(repoRoot, filePath);
|
||||
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {
|
||||
return rel.split(path.sep).join('/');
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function discoverRootsForPattern(repoRoot, rawPattern) {
|
||||
const pattern = normalizeWorkspacePattern(rawPattern);
|
||||
if (!pattern || pattern.startsWith('!')) return [];
|
||||
const segments = pattern.split('/').filter(Boolean);
|
||||
if (!segments.length) return [];
|
||||
const firstGlobIndex = segments.findIndex((segment) => segment.includes('*'));
|
||||
const literalPrefix = firstGlobIndex === -1 ? segments : segments.slice(0, firstGlobIndex);
|
||||
const base = path.join(repoRoot, ...literalPrefix);
|
||||
if (!fs.existsSync(base)) return [];
|
||||
if (segments.includes('**')) {
|
||||
const packageRoots = [];
|
||||
walkDirs(base, (dir) => {
|
||||
if (dir !== base && isCandidateProjectRoot(dir)) packageRoots.push(dir);
|
||||
});
|
||||
if (packageRoots.length) return packageRoots;
|
||||
return directChildDirs(base);
|
||||
}
|
||||
return expandSimplePattern(repoRoot, segments);
|
||||
}
|
||||
|
||||
function expandSimplePattern(repoRoot, patternSegments, index = 0, current = repoRoot) {
|
||||
if (index >= patternSegments.length) return fs.existsSync(current) ? [current] : [];
|
||||
const segment = patternSegments[index];
|
||||
if (!segment.includes('*')) {
|
||||
return expandSimplePattern(repoRoot, patternSegments, index + 1, path.join(current, segment));
|
||||
}
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(current, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const roots = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
||||
if (!segmentMatches(segment, entry.name)) continue;
|
||||
roots.push(...expandSimplePattern(repoRoot, patternSegments, index + 1, path.join(current, entry.name)));
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
function directChildDirs(dir) {
|
||||
try {
|
||||
return fs.readdirSync(dir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() && !isIgnoredWorkspaceDiscoveryDir(entry.name))
|
||||
.map((entry) => path.join(dir, entry.name));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function walkDirs(root, visit) {
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(root, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
||||
const dir = path.join(root, entry.name);
|
||||
visit(dir);
|
||||
walkDirs(dir, visit);
|
||||
}
|
||||
}
|
||||
|
||||
function isCandidateProjectRoot(dir) {
|
||||
return !!(
|
||||
fs.existsSync(path.join(dir, 'package.json'))
|
||||
|| firstExisting(dir, [...PRODUCT_NAMES, ...DESIGN_NAMES])
|
||||
|| fs.existsSync(path.join(dir, 'src'))
|
||||
|| fs.existsSync(path.join(dir, 'app'))
|
||||
|| fs.existsSync(path.join(dir, 'pages'))
|
||||
|| fs.existsSync(path.join(dir, 'public'))
|
||||
);
|
||||
}
|
||||
|
||||
function isIgnoredWorkspaceDiscoveryDir(name) {
|
||||
return name.startsWith('.') || WORKSPACE_DISCOVERY_IGNORED_DIRS.has(name);
|
||||
}
|
||||
|
||||
function findTargetExample(repoRoot, projectRoot) {
|
||||
const examples = [
|
||||
'src/App.jsx',
|
||||
'src/App.tsx',
|
||||
'src/main.jsx',
|
||||
'src/main.tsx',
|
||||
'src/index.jsx',
|
||||
'src/index.ts',
|
||||
'app/page.tsx',
|
||||
'pages/index.tsx',
|
||||
'public/index.html',
|
||||
];
|
||||
for (const rel of examples) {
|
||||
const abs = path.join(projectRoot, rel);
|
||||
if (fs.existsSync(abs)) return path.relative(repoRoot, abs).split(path.sep).join('/');
|
||||
}
|
||||
return path.relative(repoRoot, projectRoot).split(path.sep).join('/');
|
||||
}
|
||||
|
||||
function resolveWorkspaceProjectRoot(repoRoot, targetDir) {
|
||||
const rel = path.relative(repoRoot, targetDir);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return repoRoot;
|
||||
const relSegments = rel.split(path.sep).filter(Boolean);
|
||||
const patterns = readWorkspacePatterns(repoRoot);
|
||||
const excluded = isExcludedByWorkspacePattern(relSegments, patterns);
|
||||
if (!excluded) {
|
||||
for (const pattern of patterns) {
|
||||
const projectRoot = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern);
|
||||
if (projectRoot) return projectRoot;
|
||||
}
|
||||
}
|
||||
if (excluded) return repoRoot;
|
||||
if (
|
||||
relSegments.length >= 2
|
||||
&& MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0])
|
||||
) {
|
||||
return path.join(repoRoot, relSegments[0], relSegments[1]);
|
||||
}
|
||||
const nearest = nearestProjectLikeRoot(repoRoot, targetDir);
|
||||
if (nearest) return nearest;
|
||||
return repoRoot;
|
||||
}
|
||||
|
||||
function isExcludedByWorkspacePattern(relSegments, patterns) {
|
||||
return patterns.some((rawPattern) => {
|
||||
const pattern = normalizeWorkspacePattern(rawPattern);
|
||||
if (!pattern.startsWith('!')) return false;
|
||||
return workspacePatternMatchesRel(pattern.slice(1), relSegments);
|
||||
});
|
||||
}
|
||||
|
||||
function nearestProjectLikeRoot(repoRoot, targetDir) {
|
||||
let dir = path.resolve(targetDir);
|
||||
const stop = path.resolve(repoRoot);
|
||||
while (dir && dir !== stop) {
|
||||
if (
|
||||
firstExisting(dir, [...PRODUCT_NAMES, ...DESIGN_NAMES])
|
||||
|| fs.existsSync(path.join(dir, 'package.json'))
|
||||
) {
|
||||
return dir;
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function nearestPackageRootBetween(repoRoot, targetDir, stopDir) {
|
||||
let dir = path.resolve(targetDir);
|
||||
const stop = path.resolve(stopDir || repoRoot);
|
||||
const root = path.resolve(repoRoot);
|
||||
while (dir && dir !== stop && isPathInsideOrEqual(dir, root)) {
|
||||
if (fs.existsSync(path.join(dir, 'package.json'))) return dir;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPathInsideOrEqual(candidate, root) {
|
||||
return path.resolve(candidate) === path.resolve(root) || isPathInside(candidate, root);
|
||||
}
|
||||
|
||||
function workspacePatternMatchesRel(pattern, relSegments) {
|
||||
const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return false;
|
||||
if (patternSegments.includes('**')) {
|
||||
const firstGlobIndex = patternSegments.findIndex((segment) => segment.includes('*'));
|
||||
const literalPrefix = firstGlobIndex === -1
|
||||
? patternSegments
|
||||
: patternSegments.slice(0, firstGlobIndex);
|
||||
if (relSegments.length < literalPrefix.length + 1) return false;
|
||||
for (let i = 0; i < literalPrefix.length; i++) {
|
||||
if (!segmentMatches(literalPrefix[i], relSegments[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (relSegments.length < patternSegments.length) return false;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function readWorkspacePatterns(repoRoot) {
|
||||
return [
|
||||
...readPackageWorkspaces(repoRoot),
|
||||
...readPnpmWorkspaces(repoRoot),
|
||||
...readLernaWorkspaces(repoRoot),
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
function readPackageWorkspaces(repoRoot) {
|
||||
const pkg = readJson(path.join(repoRoot, 'package.json'));
|
||||
const workspaces = pkg?.workspaces;
|
||||
if (Array.isArray(workspaces)) return workspaces;
|
||||
if (Array.isArray(workspaces?.packages)) return workspaces.packages;
|
||||
return [];
|
||||
}
|
||||
|
||||
function readLernaWorkspaces(repoRoot) {
|
||||
const lerna = readJson(path.join(repoRoot, 'lerna.json'));
|
||||
return Array.isArray(lerna?.packages) ? lerna.packages : [];
|
||||
}
|
||||
|
||||
function readPnpmWorkspaces(repoRoot) {
|
||||
try {
|
||||
const body = fs.readFileSync(path.join(repoRoot, 'pnpm-workspace.yaml'), 'utf-8');
|
||||
const patterns = [];
|
||||
let inPackages = false;
|
||||
for (const line of body.split(/\r?\n/)) {
|
||||
const trimmed = stripYamlInlineComment(line).trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const flowMatch = trimmed.match(/^packages:\s*\[(.*)\]\s*$/);
|
||||
if (flowMatch) {
|
||||
patterns.push(...parseYamlFlowList(flowMatch[1]));
|
||||
inPackages = false;
|
||||
continue;
|
||||
}
|
||||
if (/^packages:\s*$/.test(trimmed)) {
|
||||
inPackages = true;
|
||||
continue;
|
||||
}
|
||||
if (inPackages && /^[A-Za-z0-9_-]+:\s*/.test(trimmed)) break;
|
||||
if (inPackages) {
|
||||
const match = trimmed.match(/^-\s*(.+)$/);
|
||||
if (match) patterns.push(unquoteYamlValue(match[1]));
|
||||
}
|
||||
}
|
||||
return patterns;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function stripYamlInlineComment(line) {
|
||||
let quote = null;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
if ((ch === '"' || ch === "'") && line[i - 1] !== '\\') {
|
||||
quote = quote === ch ? null : quote || ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === '#' && !quote) return line.slice(0, i);
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
function parseYamlFlowList(body) {
|
||||
const items = [];
|
||||
let quote = null;
|
||||
let current = '';
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
const ch = body[i];
|
||||
if ((ch === '"' || ch === "'") && body[i - 1] !== '\\') {
|
||||
quote = quote === ch ? null : quote || ch;
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === ',' && !quote) {
|
||||
const value = unquoteYamlValue(current);
|
||||
if (value) items.push(value);
|
||||
current = '';
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
}
|
||||
const value = unquoteYamlValue(current);
|
||||
if (value) items.push(value);
|
||||
return items;
|
||||
}
|
||||
|
||||
function unquoteYamlValue(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '');
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function projectRootFromWorkspacePattern(repoRoot, relSegments, rawPattern) {
|
||||
const pattern = normalizeWorkspacePattern(rawPattern);
|
||||
if (!pattern || pattern.startsWith('!')) return null;
|
||||
const patternSegments = pattern.split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return null;
|
||||
if (patternSegments.includes('**')) {
|
||||
return projectRootFromDoubleStarPattern(repoRoot, relSegments, patternSegments);
|
||||
}
|
||||
if (relSegments.length < patternSegments.length) return null;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return null;
|
||||
}
|
||||
return path.join(repoRoot, ...relSegments.slice(0, patternSegments.length));
|
||||
}
|
||||
|
||||
function projectRootFromDoubleStarPattern(repoRoot, relSegments, patternSegments) {
|
||||
const firstGlobIndex = patternSegments.findIndex((segment) => segment.includes('*'));
|
||||
const literalPrefix = firstGlobIndex === -1
|
||||
? patternSegments
|
||||
: patternSegments.slice(0, firstGlobIndex);
|
||||
if (relSegments.length < literalPrefix.length + 1) return null;
|
||||
for (let i = 0; i < literalPrefix.length; i++) {
|
||||
if (!segmentMatches(literalPrefix[i], relSegments[i])) return null;
|
||||
}
|
||||
const prefixDir = path.join(repoRoot, ...literalPrefix);
|
||||
const targetDir = path.join(repoRoot, ...relSegments);
|
||||
const packageRoot = nearestPackageRootBetween(repoRoot, targetDir, prefixDir);
|
||||
if (packageRoot) return packageRoot;
|
||||
return path.join(repoRoot, ...relSegments.slice(0, literalPrefix.length + 1));
|
||||
}
|
||||
|
||||
function normalizeWorkspacePattern(pattern) {
|
||||
return String(pattern || '')
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '')
|
||||
.replace(/^\.\//, '')
|
||||
.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function segmentMatches(patternSegment, relSegment) {
|
||||
if (patternSegment === '*') return true;
|
||||
if (!patternSegment.includes('*')) return patternSegment === relSegment;
|
||||
const re = new RegExp(`^${escapeRegExp(patternSegment).replace(/\\\*/g, '[^/]*')}$`);
|
||||
return re.test(relSegment);
|
||||
}
|
||||
|
||||
function firstExisting(dir, names) {
|
||||
for (const name of names) {
|
||||
const abs = path.join(dir, name);
|
||||
@@ -89,6 +664,10 @@ function safeRead(p) {
|
||||
}
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the register (`brand` or `product`) out of PRODUCT.md by looking
|
||||
* for a `## Register` section and reading the first non-empty line that
|
||||
@@ -233,7 +812,24 @@ async function computeUpdateDirective(now = Date.now()) {
|
||||
}
|
||||
|
||||
async function cli() {
|
||||
const ctx = loadContext(process.cwd());
|
||||
let cliOptions;
|
||||
try {
|
||||
cliOptions = parseCliOptions(process.argv.slice(2));
|
||||
} catch (err) {
|
||||
if (err?.name === 'TargetArgError') {
|
||||
process.stderr.write(`${err.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const targetProvided = hasTargetOption(cliOptions);
|
||||
const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null;
|
||||
const selection = resolveTargetSelection(process.cwd(), cliOptions);
|
||||
if (selection) {
|
||||
process.stdout.write(buildTargetSelectionDirective(selection) + '\n');
|
||||
process.exit(0);
|
||||
}
|
||||
const ctx = loadContext(process.cwd(), cliOptions);
|
||||
const updateDirective = await computeUpdateDirective();
|
||||
|
||||
if (!ctx.hasProduct) {
|
||||
@@ -244,6 +840,10 @@ async function cli() {
|
||||
'Stop the current task, load reference/init.md, and follow its ' +
|
||||
'instructions to write PRODUCT.md before resuming.',
|
||||
];
|
||||
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
|
||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||
parts.push(buildMissingTargetDirective());
|
||||
}
|
||||
if (updateDirective) parts.push(updateDirective);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
process.exit(0);
|
||||
@@ -252,6 +852,10 @@ async function cli() {
|
||||
if (ctx.hasDesign) {
|
||||
parts.push(`# DESIGN.md\n\n${ctx.design.trim()}`);
|
||||
}
|
||||
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
|
||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||
parts.push(buildMissingTargetDirective());
|
||||
}
|
||||
const register = extractRegister(ctx.product);
|
||||
const next = register
|
||||
? `NEXT STEP: This project's register is \`${register}\`. You MUST now read \`reference/${register}.md\` before producing any design output.`
|
||||
@@ -261,6 +865,60 @@ async function cli() {
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
}
|
||||
|
||||
function parseCliOptions(args) {
|
||||
return parseTargetOptions(args, { strict: true });
|
||||
}
|
||||
|
||||
function hasTargetOption(options) {
|
||||
return !!(options && typeof options.targetPath === 'string' && options.targetPath.trim());
|
||||
}
|
||||
|
||||
function pathExistsForTarget(cwd, targetPath) {
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
return fs.existsSync(abs);
|
||||
}
|
||||
|
||||
function buildResolvedContextDirective(ctx, options, { targetExists = null } = {}) {
|
||||
const targetPath = hasTargetOption(options) ? options.targetPath : null;
|
||||
return `RESOLVED_CONTEXT:\n${JSON.stringify({
|
||||
targetPath,
|
||||
...(targetPath ? { targetExists } : {}),
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
productPath: ctx.productPath,
|
||||
designPath: ctx.designPath,
|
||||
}, null, 2)}`;
|
||||
}
|
||||
|
||||
function shouldWarnMissingTarget(ctx, targetProvided, targetExists = null) {
|
||||
if (ctx.isMonorepo && targetProvided && targetExists === false) return true;
|
||||
return !!(
|
||||
ctx.isMonorepo
|
||||
&& (!targetProvided || targetExists === false)
|
||||
&& ctx.projectRoot
|
||||
&& ctx.repoRoot
|
||||
&& path.resolve(ctx.projectRoot) === path.resolve(ctx.repoRoot)
|
||||
);
|
||||
}
|
||||
|
||||
function buildMissingTargetDirective() {
|
||||
const script = process.argv[1] || 'context.mjs';
|
||||
return (
|
||||
'MONOREPO_TARGET_REQUIRED: This is a monorepo and context.mjs ran without --target. ' +
|
||||
'If the user named a file, route, or child app, do not answer from this output. ' +
|
||||
`Rerun \`node ${script} --target <path>\` and answer from that run's RESOLVED_CONTEXT fields.`
|
||||
);
|
||||
}
|
||||
|
||||
function buildTargetSelectionDirective(selection) {
|
||||
return (
|
||||
`TARGET_SELECTION_REQUIRED:\n${JSON.stringify(selection, null, 2)}\n\n` +
|
||||
'Show each app with its productStatus/productPath and designStatus/designPath so the user can see child overrides, inherited root files, fallback files, or missing files before choosing. ' +
|
||||
'Ask the user which app Impeccable should use, then rerun Impeccable helper commands from that child app cwd using this same scripts directory. ' +
|
||||
'Use `--target <path>` only as a fallback when changing cwd is not possible, or when the user explicitly named a file/path.'
|
||||
);
|
||||
}
|
||||
|
||||
// Run cli() only when this module is the entry point. Compare realpaths
|
||||
// rather than endsWith(): a loose suffix match also fires for unrelated
|
||||
// scripts like `load-context.mjs`, and realpath tolerates symlinked
|
||||
|
||||
@@ -1,50 +1,52 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { resolveProjectRoot } from '../context.mjs';
|
||||
|
||||
export const IMPECCABLE_DIR = '.impeccable';
|
||||
export const LIVE_DIR = 'live';
|
||||
export const CRITIQUE_DIR = 'critique';
|
||||
|
||||
export function getImpeccableDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, IMPECCABLE_DIR);
|
||||
export function getImpeccableDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), IMPECCABLE_DIR);
|
||||
}
|
||||
|
||||
export function getDesignSidecarPath(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), 'design.json');
|
||||
export function getDesignSidecarPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), 'design.json');
|
||||
}
|
||||
|
||||
export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd) {
|
||||
export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd, options = {}) {
|
||||
const projectRoot = resolveProjectRoot(cwd, options);
|
||||
const candidates = [
|
||||
getDesignSidecarPath(cwd),
|
||||
path.join(cwd, 'DESIGN.json'),
|
||||
getDesignSidecarPath(cwd, options),
|
||||
path.join(projectRoot, 'DESIGN.json'),
|
||||
];
|
||||
const contextLegacy = path.join(contextDir, 'DESIGN.json');
|
||||
if (!candidates.includes(contextLegacy)) candidates.push(contextLegacy);
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) {
|
||||
return firstExisting(getDesignSidecarCandidates(cwd, contextDir));
|
||||
export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd, options = {}) {
|
||||
return firstExisting(getDesignSidecarCandidates(cwd, contextDir, options));
|
||||
}
|
||||
|
||||
export function getLiveDir(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), LIVE_DIR);
|
||||
export function getLiveDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), LIVE_DIR);
|
||||
}
|
||||
|
||||
export function getLiveConfigPath(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'config.json');
|
||||
export function getLiveConfigPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'config.json');
|
||||
}
|
||||
|
||||
export function getLegacyLiveConfigPath(scriptsDir) {
|
||||
return path.join(scriptsDir, 'config.json');
|
||||
}
|
||||
|
||||
export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env } = {}) {
|
||||
export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env, targetPath } = {}) {
|
||||
if (env.IMPECCABLE_LIVE_CONFIG && env.IMPECCABLE_LIVE_CONFIG.trim()) {
|
||||
const configured = env.IMPECCABLE_LIVE_CONFIG.trim();
|
||||
return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured);
|
||||
}
|
||||
const primary = getLiveConfigPath(cwd);
|
||||
const primary = getLiveConfigPath(cwd, { targetPath });
|
||||
if (fs.existsSync(primary)) return primary;
|
||||
if (scriptsDir) {
|
||||
const legacy = getLegacyLiveConfigPath(scriptsDir);
|
||||
@@ -53,16 +55,16 @@ export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = p
|
||||
return primary;
|
||||
}
|
||||
|
||||
export function getLiveServerPath(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'server.json');
|
||||
export function getLiveServerPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'server.json');
|
||||
}
|
||||
|
||||
export function getLegacyLiveServerPath(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live.json');
|
||||
export function getLegacyLiveServerPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live.json');
|
||||
}
|
||||
|
||||
export function readLiveServerInfo(cwd = process.cwd()) {
|
||||
for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) {
|
||||
export function readLiveServerInfo(cwd = process.cwd(), options = {}) {
|
||||
for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) {
|
||||
try {
|
||||
const info = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
if (info && typeof info.pid === 'number' && !isLiveServerPidReachable(info.pid)) {
|
||||
@@ -88,37 +90,37 @@ export function isLiveServerPidReachable(pid) {
|
||||
}
|
||||
}
|
||||
|
||||
export function writeLiveServerInfo(cwd = process.cwd(), info) {
|
||||
const filePath = getLiveServerPath(cwd);
|
||||
export function writeLiveServerInfo(cwd = process.cwd(), info, options = {}) {
|
||||
const filePath = getLiveServerPath(cwd, options);
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(info));
|
||||
return filePath;
|
||||
}
|
||||
|
||||
export function removeLiveServerInfo(cwd = process.cwd()) {
|
||||
for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) {
|
||||
export function removeLiveServerInfo(cwd = process.cwd(), options = {}) {
|
||||
for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) {
|
||||
try { fs.unlinkSync(filePath); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
export function getLiveSessionsDir(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'sessions');
|
||||
export function getLiveSessionsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'sessions');
|
||||
}
|
||||
|
||||
export function getLegacyLiveSessionsDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live', 'sessions');
|
||||
export function getLegacyLiveSessionsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'sessions');
|
||||
}
|
||||
|
||||
export function getLiveAnnotationsDir(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'annotations');
|
||||
export function getLiveAnnotationsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'annotations');
|
||||
}
|
||||
|
||||
export function getCritiqueDir(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), CRITIQUE_DIR);
|
||||
export function getCritiqueDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), CRITIQUE_DIR);
|
||||
}
|
||||
|
||||
export function getLegacyLiveAnnotationsDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live', 'annotations');
|
||||
export function getLegacyLiveAnnotationsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'annotations');
|
||||
}
|
||||
|
||||
function firstExisting(paths) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user