Redesign an existing website to make it better. Built on top of impeccable.
71
89%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Low
Low-risk findings worth noting
#!/usr/bin/env node
/**
* rollout/inventory.mjs — build the delivery coverage from a migrated tree.
*
* Reads stardust/migrated/ (the platform-agnostic output of `migrate`) plus the
* per-page _meta.json sidecars, and writes the rollout coverage:
* - stardust/rollout/coverage/pages.json (one row per migrated page)
* - stardust/rollout/coverage/templates.json (grouping for delivery order/reuse)
* - stardust/rollout/rollout.json (config + lastRun summary; created if absent)
*
* Idempotent + incremental: existing delivery status is preserved. When a page's
* migrated HTML changed (sourceHash differs) after it was already deployed/verified,
* it is re-flagged `stale` so the delivery loop re-delivers just that page.
*
* Phase 1 scope: pages + template grouping only. Block dedup (blocks.json) is a
* first-class step deferred to Phase 2 — see notes/rollout/PLAN.md § 8.
*
* Archetypes-only mode (`--state <state.json>`): the migrated tree holds only the
* template archetypes (one per template); the full page roster lives in state.json.
* Pages present only in state.json (not yet individually migrated) are merged in as
* `content-pending` siblings — keyed to the archetype their `type` names, inheriting
* that archetype's blocks, pushing no document. Block code ships from the archetypes;
* sibling content is populated later by a separate content track.
*
* Usage:
* node skills/rollout/scripts/inventory.mjs [--migrated <dir>] [--out <rolloutDir>] [--site-url <url>] [--state <state.json>]
* defaults: --migrated stardust/migrated --out stardust/rollout
*/
import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync, statSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { join, dirname, relative, basename } from 'node:path';
import { fileURLToPath } from 'node:url';
function arg(name, fallback) {
const i = process.argv.indexOf(`--${name}`);
return i !== -1 && process.argv[i + 1] ? process.argv[i + 1] : fallback;
}
const MIGRATED = arg('migrated', 'stardust/migrated');
const OUT = arg('out', 'stardust/rollout');
const SITE_URL = arg('site-url', null);
const STATE = arg('state', null);
const STARDUST_VERSION = (() => {
try {
const here = dirname(fileURLToPath(import.meta.url));
const pj = JSON.parse(readFileSync(join(here, '..', '..', '..', '.claude-plugin', 'plugin.json'), 'utf8'));
return pj.version || '0.0.0';
} catch { return '0.0.0'; }
})();
function sha256(buf) { return `sha256:${createHash('sha256').update(buf).digest('hex')}`; }
/** Recursively collect *.html under dir, skipping the assets/ bundle. */
function walkHtml(dir, root = dir, acc = []) {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (entry.name.startsWith('.')) continue;
const full = join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === 'assets') continue;
walkHtml(full, root, acc);
} else if (entry.name.endsWith('.html')) {
acc.push(relative(root, full));
}
}
return acc;
}
/** Sidecar path for a migrated HTML rel path (index.html -> _meta.json; x.html -> x._meta.json). */
function sidecarFor(relHtml) {
const dir = dirname(relHtml);
const leaf = basename(relHtml);
const name = leaf === 'index.html' ? '_meta.json' : `${leaf.replace(/\.html$/, '')}._meta.json`;
return dir === '.' ? name : join(dir, name);
}
/** Delivered (extensionless) path on the EDS site for a migrated HTML rel path. */
function deliveredPath(relHtml) {
const noExt = relHtml.replace(/\.html$/, '');
if (noExt === 'index') return '/';
if (noExt.endsWith('/index')) return `/${noExt.slice(0, -'/index'.length)}`;
return `/${noExt}`;
}
/** Delivered (extensionless) path for an absolute page URL (archetypes-only roster). */
function urlToDeliveredPath(url) {
let p;
try { p = new URL(url).pathname; } catch { p = String(url); }
p = p.replace(/\.html$/, '');
if (p === '' || p === '/' || p === '/index') return '/';
if (p.endsWith('/index')) return p.slice(0, -'/index'.length);
return p;
}
/** Stable slug from a delivered path (de/x/y -> de-x-y; '/' -> home). */
function pathToSlug(path) {
if (path === '/') return 'home';
return path.replace(/^\//, '').replace(/\/+$/, '').replace(/\//g, '-');
}
function readJSON(path, fallback = null) {
try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return fallback; }
}
if (!existsSync(MIGRATED)) {
console.error(`rollout inventory: migrated tree not found at "${MIGRATED}". Run \`stardust migrate\` first.`);
process.exit(1);
}
// --- Load prior coverage to preserve delivery state ----------------------------
const coverageDir = join(OUT, 'coverage');
const pagesPath = join(coverageDir, 'pages.json');
const templatesPath = join(coverageDir, 'templates.json');
const configPath = join(OUT, 'rollout.json');
const priorPages = readJSON(pagesPath, { pages: [] });
const priorBySlug = new Map((priorPages.pages || []).map((p) => [p.slug, p]));
// --- Inventory the migrated tree -----------------------------------------------
const htmlFiles = walkHtml(MIGRATED).sort();
const pages = [];
for (const relHtml of htmlFiles) {
const absHtml = join(MIGRATED, relHtml);
const sourceHash = sha256(readFileSync(absHtml));
const sidecarRel = sidecarFor(relHtml);
const meta = readJSON(join(MIGRATED, sidecarRel), {});
const path = deliveredPath(relHtml);
const slug = meta.slug || (path === '/' ? 'home' : path.replace(/^\//, ''));
const prior = priorBySlug.get(slug);
let delivery;
if (prior && prior.delivery) {
const changed = prior.source && prior.source.sourceHash !== sourceHash;
if (changed && ['deployed', 'verified'].includes(prior.delivery.status)) {
delivery = { ...prior.delivery, status: 'stale' };
} else {
delivery = prior.delivery;
}
} else {
delivery = { status: 'pending', deployedUrl: null, deployedAt: null, verifiedAt: null, error: null };
}
pages.push({
slug,
path,
title: (meta.metadata && meta.metadata.title) || meta.slug || slug,
templateId: meta.template || meta.type || null,
source: {
migratedHtml: join(MIGRATED, relHtml),
metaJson: existsSync(join(MIGRATED, sidecarRel)) ? join(MIGRATED, sidecarRel) : null,
sourceHash,
},
blocks: Array.isArray(meta.modules) ? meta.modules : [],
delivery,
});
}
// --- Archetypes-only mode: merge content-pending siblings from state.json -------
// In --state mode each migrated archetype is keyed by its own fine slug (the
// archetypes are genuinely distinct templates that share only chrome). A roster
// page's `type` names the archetype slug it clones; it joins that archetype's
// template group as a `content-pending` sibling, inheriting the archetype's blocks
// and pushing no document of its own.
if (STATE) {
for (const p of pages) p.templateId = p.slug;
const archetypeBySlug = new Map(pages.map((p) => [p.slug, p]));
const coveredPaths = new Set(pages.map((p) => p.path));
const usedSlugs = new Set(pages.map((p) => p.slug));
const state = readJSON(STATE, { pages: [] });
for (const sp of (state.pages || [])) {
if (!sp || !sp.url) continue;
const path = urlToDeliveredPath(sp.url);
if (coveredPaths.has(path)) continue; // the archetype itself — already inventoried
// A roster sibling names the archetype it clones via `representative` (preferred)
// or its `type`; that archetype supplies the template grouping + block set.
const rep = archetypeBySlug.get(sp.representative) || archetypeBySlug.get(sp.type) || null;
let slug = sp.slug && !usedSlugs.has(sp.slug) ? sp.slug : pathToSlug(path);
while (usedSlugs.has(slug)) slug = `${slug}-x`;
usedSlugs.add(slug);
const csPath = sp.currentStatePath && existsSync(sp.currentStatePath) ? sp.currentStatePath : null;
const sourceHash = csPath ? sha256(readFileSync(csPath)) : sha256(Buffer.from(String(sp.url)));
const prior = priorBySlug.get(slug);
const delivery = (prior && prior.delivery)
? prior.delivery
: { status: 'content-pending', deployedUrl: null, deployedAt: null, verifiedAt: null, error: null };
pages.push({
slug,
path,
title: sp.title || slug,
templateId: rep ? rep.slug : (sp.type || 'untyped'),
representative: rep ? rep.slug : null,
source: { migratedHtml: null, metaJson: null, sourceHash, currentStatePath: csPath },
blocks: rep ? rep.blocks.slice() : [],
delivery,
});
coveredPaths.add(path);
}
}
pages.sort((a, b) => a.slug.localeCompare(b.slug));
// --- Derive the template grouping ----------------------------------------------
const tmap = new Map();
for (const p of pages) {
const id = p.templateId || 'untyped';
if (!tmap.has(id)) tmap.set(id, { id, pages: [], blocks: new Set() });
const t = tmap.get(id);
t.pages.push(p.slug);
p.blocks.forEach((b) => t.blocks.add(b));
}
const templates = [...tmap.values()].map((t) => {
const tp = pages.filter((p) => t.pages.includes(p.slug));
const count = (s) => tp.filter((p) => p.delivery.status === s).length;
// The representative is the migrated archetype that converts the blocks — never a
// content-pending sibling (which pushes no document).
const rep = tp.find((p) => p.delivery.status !== 'content-pending') || tp[0];
return {
id: t.id,
representativeSlug: (rep && rep.slug) || null,
pages: t.pages.sort(),
pageCount: t.pages.length,
blocks: [...t.blocks].sort(),
delivery: {
verified: count('verified'),
deployed: count('deployed'),
contentPending: count('content-pending'),
pending: t.pages.length - count('verified') - count('deployed') - count('content-pending'),
},
};
}).sort((a, b) => a.id.localeCompare(b.id));
// --- Counts summary ------------------------------------------------------------
const status = (s) => pages.filter((p) => p.delivery.status === s).length;
const counts = {
total: pages.length,
verified: status('verified'),
deployed: status('deployed'),
pending: status('pending'),
contentPending: status('content-pending'),
stale: status('stale'),
failed: status('failed'),
};
// --- Write ---------------------------------------------------------------------
const now = new Date().toISOString();
mkdirSync(coverageDir, { recursive: true });
const prov = (writtenBy) => ({
writtenBy, writtenAt: now, readArtifacts: STATE ? [MIGRATED, STATE] : [MIGRATED], stardustVersion: STARDUST_VERSION,
});
writeFileSync(pagesPath, `${JSON.stringify({ _provenance: prov('stardust:rollout/inventory'), generatedAt: now, pages }, null, 2)}\n`);
writeFileSync(templatesPath, `${JSON.stringify({ _provenance: prov('stardust:rollout/inventory'), generatedAt: now, templates }, null, 2)}\n`);
const priorConfig = readJSON(configPath, null);
const config = {
_provenance: { writtenBy: 'stardust:rollout/inventory', writtenAt: now, stardustVersion: STARDUST_VERSION },
target: 'aem-eds',
site: {
sourceUrl: SITE_URL || (priorConfig && priorConfig.site && priorConfig.site.sourceUrl) || 'about:blank',
da: (priorConfig && priorConfig.site && priorConfig.site.da) || { org: '', site: '', ref: 'main' },
liveHost: (priorConfig && priorConfig.site && priorConfig.site.liveHost) || null,
},
lastRun: { at: now, pages: counts, blocks: { total: 0, converted: 0, pending: 0 }, verifyFailures: 0 },
};
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
// --- Report --------------------------------------------------------------------
console.log(`rollout inventory → ${OUT}`);
console.log('='.repeat(60));
console.log(`Pages ${counts.total} total · ${counts.verified} verified · ${counts.deployed} deployed · ${counts.pending} pending · ${counts.contentPending} content-pending · ${counts.stale} stale`);
console.log(`Templates ${templates.length} (${templates.map((t) => `${t.id}:${t.pageCount}`).join(', ')})`);
const todo = pages.filter((p) => ['pending', 'stale', 'failed'].includes(p.delivery.status));
if (todo.length) console.log(`To deliver ${todo.length}: ${todo.slice(0, 8).map((p) => p.slug).join(', ')}${todo.length > 8 ? ' …' : ''}`);
else console.log('To deliver 0 — all pages delivered.');.tessl-plugin
skills
audit
reference
deploy
diff
direct
extract
migrate
prepare-migration
prototype
replica
reskin
rollout
stardust
uplift
reference