@nextcommerce/campaigns-os 1.47.0 → 1.50.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +307 -0
- package/README.md +30 -3
- package/agents/claude/CLAUDE.md +6 -3
- package/agents/codex/AGENTS.md +6 -4
- package/agents/copilot/copilot-instructions.md +3 -2
- package/agents/cursor/campaigns-os.mdc +3 -3
- package/compatibility.json +1 -1
- package/contracts/commerce-surface-catalog.json +17 -17
- package/contracts/effects.v1.json +173 -0
- package/contracts/release-ledger.json +798 -0
- package/contracts/supported-surface.json +4 -4
- package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
- package/docs/brand-theme-bridge.md +12 -6
- package/docs/build-packet.md +9 -4
- package/docs/campaign-build-brief.md +25 -28
- package/docs/local-setup.md +7 -2
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/qa-and-test-orders.md +70 -9
- package/docs/runtime-readiness.md +1 -1
- package/docs/skills-revision.md +10 -10
- package/package.json +1 -1
- package/schemas/campaign-runtime-build-packet.v0.schema.json +4 -0
- package/schemas/campaigns-os-qa-verdict.v0.schema.json +8 -3
- package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
- package/skills/campaign-readback-classification/SKILL.md +3 -3
- package/skills/campaign-run-evidence/SKILL.md +9 -4
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +5 -4
- package/skills/next-campaigns-os/SKILL.md +3 -3
- package/skills/next-campaigns-os-setup/SKILL.md +3 -3
- package/skills/next-campaigns-polish/SKILL.md +6 -5
- package/skills/next-campaigns-qa/SKILL.md +3 -3
- package/skills.json +10 -10
- package/src/brand-theme.mjs +13 -2
- package/src/cli.mjs +17 -12
- package/src/content-residue.mjs +18 -90
- package/src/doctor/checks.mjs +21 -29
- package/src/doctor/inspect.mjs +13 -3
- package/src/doctor/next-step.mjs +4 -0
- package/src/gate-actions.mjs +8 -0
- package/src/install-mode.mjs +0 -8
- package/src/invocation.mjs +3 -1
- package/src/local-preview-policy.mjs +92 -0
- package/src/page-kit-sdk-version.mjs +8 -1
- package/src/polish-node.mjs +26 -2
- package/src/progress-node.mjs +5 -1
- package/src/qa-binding-evidence.mjs +22 -1
- package/src/qa-browser.mjs +92 -14
- package/src/qa-node.mjs +43 -6
- package/src/readback.mjs +19 -10
- package/src/source-prep.mjs +1 -1
- package/src/stage-record.mjs +303 -22
- package/src/theme-gate.mjs +3 -3
package/src/readback.mjs
CHANGED
|
@@ -1065,8 +1065,11 @@ function artifactData(views, key) {
|
|
|
1065
1065
|
}
|
|
1066
1066
|
|
|
1067
1067
|
/**
|
|
1068
|
-
* Bucket the QA verdict's assertions by recorded status
|
|
1068
|
+
* Bucket the QA verdict's assertions by recorded status: `{ fail, pass,
|
|
1069
|
+
* skipped, review, other }`.
|
|
1069
1070
|
*
|
|
1071
|
+
* `review` collects the verdict's `warn` and `manual_review` statuses: evidence
|
|
1072
|
+
* that still needs reading, which the disposition counts as an exception.
|
|
1070
1073
|
* `other` collects any status this readback does not project (it renders those
|
|
1071
1074
|
* rows as written rather than reclassifying them). Non-object entries are
|
|
1072
1075
|
* dropped: an assertion the readback cannot address by status is not an
|
|
@@ -1075,13 +1078,15 @@ function artifactData(views, key) {
|
|
|
1075
1078
|
* branch.
|
|
1076
1079
|
*/
|
|
1077
1080
|
export function partitionAssertions(views) {
|
|
1078
|
-
const buckets = { fail: [], pass: [], skipped: [], other: [] };
|
|
1081
|
+
const buckets = { fail: [], pass: [], skipped: [], review: [], other: [] };
|
|
1079
1082
|
const verdict = artifactData(views, "qa_verdict");
|
|
1080
1083
|
if (verdict === null) return buckets;
|
|
1081
1084
|
for (const assertion of verdict.assertions ?? []) {
|
|
1082
1085
|
if (!isPlainObject(assertion)) continue;
|
|
1083
1086
|
const status = assertion.status;
|
|
1084
|
-
const bucket = status === "fail" || status === "pass" || status === "skipped"
|
|
1087
|
+
const bucket = status === "fail" || status === "pass" || status === "skipped"
|
|
1088
|
+
? status
|
|
1089
|
+
: status === "warn" || status === "manual_review" ? "review" : "other";
|
|
1085
1090
|
buckets[bucket].push(assertion);
|
|
1086
1091
|
}
|
|
1087
1092
|
return buckets;
|
|
@@ -1414,15 +1419,17 @@ function renderVerdict(views, lines) {
|
|
|
1414
1419
|
lines.push(
|
|
1415
1420
|
`QA VERDICT [QA verdict; disposition: ${verdict.disposition} — Campaigns OS is the verdict authority]`,
|
|
1416
1421
|
);
|
|
1417
|
-
const { fail: failed, pass: passed, skipped, other } = partitionAssertions(views);
|
|
1422
|
+
const { fail: failed, pass: passed, skipped, review, other } = partitionAssertions(views);
|
|
1418
1423
|
let countLine = ` assertions: ${failed.length} fail, ${passed.length} pass, ${skipped.length} skipped`;
|
|
1424
|
+
if (review.length) countLine += `, ${review.length} warn or manual review`;
|
|
1419
1425
|
if (other.length) countLine += `, ${other.length} unrecognized status`;
|
|
1420
1426
|
lines.push(countLine);
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1427
|
+
// fail, warn and manual_review rows print what Campaigns OS recorded: the
|
|
1428
|
+
// row's actual value and any evidence problems.
|
|
1429
|
+
const renderRecordedRow = (assertion) => {
|
|
1430
|
+
const severityText = assertion.severity ? `, severity ${assertion.severity}` : "";
|
|
1424
1431
|
lines.push(
|
|
1425
|
-
`
|
|
1432
|
+
` ${assertion.status} ${recorded(assertion.id, "(no id)")} ` +
|
|
1426
1433
|
`(family ${recorded(assertion.family, "(no family)")}${severityText})`,
|
|
1427
1434
|
);
|
|
1428
1435
|
const actual = assertion.actual;
|
|
@@ -1433,7 +1440,9 @@ function renderVerdict(views, lines) {
|
|
|
1433
1440
|
lines.push(` recorded problems (${problems.length}):`);
|
|
1434
1441
|
for (const problem of problems) lines.push(` - ${problem}`);
|
|
1435
1442
|
}
|
|
1436
|
-
}
|
|
1443
|
+
};
|
|
1444
|
+
for (const assertion of failed) renderRecordedRow(assertion);
|
|
1445
|
+
for (const assertion of review) renderRecordedRow(assertion);
|
|
1437
1446
|
for (const assertion of passed) {
|
|
1438
1447
|
const family = assertion.family || assertion.id || "(no family)";
|
|
1439
1448
|
lines.push(` pass ${recorded(assertion.id, "(no id)")} (family ${family})`);
|
|
@@ -1442,7 +1451,7 @@ function renderVerdict(views, lines) {
|
|
|
1442
1451
|
lines.push(
|
|
1443
1452
|
` unrecognized status ${JSON.stringify(assertion.status ?? null)} ${recorded(assertion.id, "(no id)")} ` +
|
|
1444
1453
|
`(family ${assertion.family || "(no family)"}) — shown as written; this readback ` +
|
|
1445
|
-
"projects fail, pass, and
|
|
1454
|
+
"projects fail, pass, skipped, warn and manual_review statuses",
|
|
1446
1455
|
);
|
|
1447
1456
|
}
|
|
1448
1457
|
|
package/src/source-prep.mjs
CHANGED
|
@@ -201,7 +201,7 @@ function describeFinding(code, pages, { wrapperPolicy }) {
|
|
|
201
201
|
const policyNote = wrapperPolicy === "preserve_document_wrappers"
|
|
202
202
|
? " The adapter contract records wrapper_policy \"preserve_document_wrappers\", so this is reported without blocking."
|
|
203
203
|
: "";
|
|
204
|
-
return `Mapped source HTML is a full browser document, not page-kit-ready source: ${listed}${more}. Strip <!doctype>, <html>, <head>, and <body> so the campaign layout can wrap the page, or record wrapper_policy "preserve_document_wrappers" as an explicit adapter decision.${policyNote} See ${docs}.`;
|
|
204
|
+
return `Mapped source HTML is a full browser document, not page-kit-ready source: ${listed}${more}. Strip <!doctype>, <html>, <head>, and <body> so the campaign layout can wrap the page, or, for a standalone page meant to stay whole, record wrapper_policy "preserve_document_wrappers" as an explicit adapter decision: re-run start or prepare-build with --wrapper-policy preserve_document_wrappers, or set "wrapper_policy" in the source-html manifest.${policyNote} See ${docs}.`;
|
|
205
205
|
}
|
|
206
206
|
if (code === SOURCE_PREP_FRONTMATTER_RESIDUE) {
|
|
207
207
|
const listed = sample.map((page) => {
|
package/src/stage-record.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// `campaigns-os record <setup|build|polish>`: record a stage's
|
|
2
|
-
//
|
|
3
|
-
// of hand-edited JSON.
|
|
1
|
+
// `campaigns-os record <setup|build|polish|theme|deploy>`: record a stage's
|
|
2
|
+
// completion (or, for theme, an applied brand layer) on the Build Context and
|
|
3
|
+
// Assembly Report through one validated command instead of hand-edited JSON.
|
|
4
4
|
//
|
|
5
5
|
// Every value a record stamps is read from the doctor result the `next` ladder
|
|
6
6
|
// itself reads (doctorPacket over the same packet and sidecars), so a record
|
|
@@ -14,16 +14,24 @@
|
|
|
14
14
|
//
|
|
15
15
|
// The Build Context holds setup state only (`scaffold`); build and polish
|
|
16
16
|
// completion live on the Assembly Report alone, so `record build` and `record
|
|
17
|
-
// polish` validate the context they read but write only the report.
|
|
17
|
+
// polish` validate the context they read but write only the report. `record
|
|
18
|
+
// theme` writes `report.theme` only after reading, in each built commerce
|
|
19
|
+
// page, that a brand layer stylesheet is linked after next-core.css. `record
|
|
20
|
+
// deploy` records a local preview: the packet's deploy.preview_url and
|
|
21
|
+
// stages.deploy, after every built page answers on the served loopback URL.
|
|
18
22
|
import { existsSync, readFileSync } from "node:fs";
|
|
19
|
-
import { join, resolve } from "node:path";
|
|
23
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
20
24
|
import { fileURLToPath } from "node:url";
|
|
21
25
|
|
|
22
26
|
import Ajv2020 from "ajv/dist/2020.js";
|
|
23
27
|
|
|
24
|
-
import {
|
|
28
|
+
import { BRAND_LAYER_FILENAMES } from "./brand-theme.mjs";
|
|
29
|
+
import { computeBuildFingerprint, resolveBuiltSiteScope } from "./built-site-scope.mjs";
|
|
25
30
|
import { resolveCampaignWorkspace, targetRepoFor } from "./campaign-workspace.mjs";
|
|
26
31
|
import { isObject, optionalString, readJsonIfExists, requireArg } from "./cli-helpers.mjs";
|
|
32
|
+
import { isLocalServePacket } from "./local-proof.mjs";
|
|
33
|
+
import { isLoopbackHostname } from "./remit.mjs";
|
|
34
|
+
import { campaignRouteRoot } from "./route-identity.mjs";
|
|
27
35
|
import { writeJsonAtomic } from "./doctor-sidecar.mjs";
|
|
28
36
|
import { doctorPacket } from "./doctor/inspect.mjs";
|
|
29
37
|
import { validateAssemblyReport } from "./doctor/checks.mjs";
|
|
@@ -39,13 +47,15 @@ import {
|
|
|
39
47
|
import { evaluateRecordedHiddenEagerMediaCheckpoint } from "./polish-node.mjs";
|
|
40
48
|
import { applyDerivedAssemblyReportSummary, assemblyReportMatchesPacket, commitAssemblyReport } from "./stage-ledger.mjs";
|
|
41
49
|
import { withTargetLockSync } from "./target-lock.mjs";
|
|
50
|
+
import { commerceScopeFromScope } from "./theme-gate.mjs";
|
|
42
51
|
|
|
43
|
-
export const RECORD_STAGES = Object.freeze(["setup", "build", "polish"]);
|
|
52
|
+
export const RECORD_STAGES = Object.freeze(["setup", "build", "polish", "theme", "deploy"]);
|
|
44
53
|
|
|
45
54
|
// Every flag `record` reads, plus the two any command accepts (run id and
|
|
46
55
|
// lifecycle journal). Anything else is refused before a file is read.
|
|
47
56
|
const RECORD_FLAGS = Object.freeze(["packet", "context", "report", "dry-run", "json", "run-id", "lifecycle-journal"]);
|
|
48
57
|
const POLISH_RECORD_FLAGS = Object.freeze(["evidence"]);
|
|
58
|
+
const DEPLOY_RECORD_FLAGS = Object.freeze(["base-url"]);
|
|
49
59
|
|
|
50
60
|
// The keys a --evidence file may carry. `evidence` is stages.polish.evidence;
|
|
51
61
|
// `repair_loop_defect` is report.theme.repair_loop_defect; `blockers` (status
|
|
@@ -93,9 +103,9 @@ function refuseRecord(stage, problems) {
|
|
|
93
103
|
export function parseRecordArgs(args) {
|
|
94
104
|
const stage = args._[1];
|
|
95
105
|
if (!RECORD_STAGES.includes(stage) || args._.length !== 2) {
|
|
96
|
-
throw refused(`Use: ${cmd("record")} <${RECORD_STAGES.join("|")}> --packet <campaign-runtime.build.json> [--context <json>] [--report <json>] [--dry-run] [--json]; record polish also takes --evidence <polish-evidence.json>.`);
|
|
106
|
+
throw refused(`Use: ${cmd("record")} <${RECORD_STAGES.join("|")}> --packet <campaign-runtime.build.json> [--context <json>] [--report <json>] [--dry-run] [--json]; record polish also takes --evidence <polish-evidence.json>, and record deploy --base-url <served url>.`);
|
|
97
107
|
}
|
|
98
|
-
const known = new Set([...RECORD_FLAGS, ...(stage === "polish" ? POLISH_RECORD_FLAGS : [])]);
|
|
108
|
+
const known = new Set([...RECORD_FLAGS, ...(stage === "polish" ? POLISH_RECORD_FLAGS : []), ...(stage === "deploy" ? DEPLOY_RECORD_FLAGS : [])]);
|
|
99
109
|
const unknown = Object.keys(args).filter((key) => key !== "_" && !known.has(key));
|
|
100
110
|
if (unknown.length) {
|
|
101
111
|
throw refused(`Unknown flag${unknown.length > 1 ? "s" : ""} for record ${stage}: ${unknown.map((key) => `--${key}`).join(", ")}. Known flags: ${[...known].map((key) => `--${key}`).join(", ")}.`);
|
|
@@ -111,6 +121,7 @@ export function parseRecordArgs(args) {
|
|
|
111
121
|
stage,
|
|
112
122
|
packetPath: resolve(requireArg(args, "packet")),
|
|
113
123
|
evidencePath: stage === "polish" ? resolve(requireArg(args, "evidence")) : null,
|
|
124
|
+
baseUrl: stage === "deploy" ? requireArg(args, "base-url") : null,
|
|
114
125
|
dryRun: args["dry-run"] === true,
|
|
115
126
|
};
|
|
116
127
|
}
|
|
@@ -294,6 +305,223 @@ function composePolish(report, { now, recordedBy, fingerprint, input }) {
|
|
|
294
305
|
return { report: nextReport, context: null };
|
|
295
306
|
}
|
|
296
307
|
|
|
308
|
+
// The stylesheet every family's commerce pages load first; the brand layer
|
|
309
|
+
// must come after it so its --brand--* values win.
|
|
310
|
+
const CORE_STYLESHEET = "next-core.css";
|
|
311
|
+
|
|
312
|
+
function stylesheetHrefs(html) {
|
|
313
|
+
return [...html.matchAll(/<link\b[^>]*>/gi)]
|
|
314
|
+
.map((match) => match[0])
|
|
315
|
+
.filter((tag) => /\brel\s*=\s*["']?[^"'>]*\bstylesheet\b/i.test(tag))
|
|
316
|
+
.map((tag) => (tag.match(/\bhref\s*=\s*["']([^"']+)["']/i) || [])[1])
|
|
317
|
+
.filter(Boolean);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const hrefName = (href) => href.split(/[?#]/)[0].split("/").pop();
|
|
321
|
+
const routeKey = (route) => String(route || "").replace(/^\/+|\/+$/g, "");
|
|
322
|
+
|
|
323
|
+
// What `record theme` stands on, read from each built commerce page's
|
|
324
|
+
// stylesheet links in document order. A page that loads next-core.css renders
|
|
325
|
+
// family components, so it must load a brand layer (brand-theme.css or
|
|
326
|
+
// checkout-brand.css) after it, and that file must be in the built output. A
|
|
327
|
+
// page that loads neither renders the design's own markup and needs no brand
|
|
328
|
+
// layer (docs/brand-theme-bridge.md, "Where next-core.css belongs"). Any page
|
|
329
|
+
// that breaks the rule is a refusal naming the page.
|
|
330
|
+
function brandLayerFacts(doctor) {
|
|
331
|
+
const derived = doctor.derived || {};
|
|
332
|
+
const commerce = commerceScopeFromScope(derived.scope);
|
|
333
|
+
if (!commerce.all.length) {
|
|
334
|
+
throw refuseRecord("theme", ["The campaign ships no commerce pages, so there is no brand layer to record; the theme gate does not apply."]);
|
|
335
|
+
}
|
|
336
|
+
const site = resolveBuiltSiteScope(derived.target_repo, { slug: derived.public_route_slug });
|
|
337
|
+
if (!site.ok) throw refuseRecord("theme", [site.error]);
|
|
338
|
+
const byRoute = new Map(site.pages.map((page) => [routeKey(page.route), page]));
|
|
339
|
+
const byId = new Map(site.pages.map((page) => [page.page_id, page]));
|
|
340
|
+
const problems = [];
|
|
341
|
+
const evidence = [];
|
|
342
|
+
const layers = [];
|
|
343
|
+
const styled = [];
|
|
344
|
+
const unstyled = [];
|
|
345
|
+
for (const page of commerce.built) {
|
|
346
|
+
const label = page.page_id || page.route || page.type;
|
|
347
|
+
const built = byRoute.get(routeKey(page.route)) || byId.get(page.page_id);
|
|
348
|
+
if (!built) {
|
|
349
|
+
problems.push(`${label}: no built page in ${relative(derived.target_repo, site.campaign_dir) || "."}; run the page-kit build, then ${cmd("record")} build.`);
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
const builtRel = relative(derived.target_repo, built.built_path);
|
|
353
|
+
const hrefs = stylesheetHrefs(readFileSync(built.built_path, "utf8"));
|
|
354
|
+
const core = hrefs.findIndex((href) => hrefName(href) === CORE_STYLESHEET);
|
|
355
|
+
const brand = hrefs.findIndex((href, index) => index > core && BRAND_LAYER_FILENAMES.has(hrefName(href)));
|
|
356
|
+
if (core < 0) {
|
|
357
|
+
if (hrefs.some((href) => BRAND_LAYER_FILENAMES.has(hrefName(href)))) {
|
|
358
|
+
problems.push(`${label}: ${builtRel} links a brand layer but not ${CORE_STYLESHEET}. On a page built from the design's own markup, remove the brand layer; only if the page renders family components, load ${CORE_STYLESHEET} before it.`);
|
|
359
|
+
} else {
|
|
360
|
+
unstyled.push(label);
|
|
361
|
+
evidence.push(`${label}: ${builtRel} loads neither ${CORE_STYLESHEET} nor a brand layer (the design's own markup).`);
|
|
362
|
+
}
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
if (brand < 0) {
|
|
366
|
+
const early = hrefs.find((href) => BRAND_LAYER_FILENAMES.has(hrefName(href)));
|
|
367
|
+
problems.push(early
|
|
368
|
+
? `${label}: ${builtRel} links ${early} before ${CORE_STYLESHEET}; list it after ${CORE_STYLESHEET} in the page's frontmatter styles and rebuild.`
|
|
369
|
+
: `${label}: ${builtRel} links no brand layer (${[...BRAND_LAYER_FILENAMES].join(" or ")}) after ${CORE_STYLESHEET}.`);
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
const href = hrefs[brand].split(/[?#]/)[0];
|
|
373
|
+
if (/^(?:[a-z][a-z0-9+.-]*:)?\/\//i.test(href)) {
|
|
374
|
+
problems.push(`${label}: ${builtRel} loads its brand layer from another origin (${hrefs[brand]}); the brand layer ships with the campaign, so copy it into the campaign assets and link that copy.`);
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
const file = href.startsWith("/") ? join(site.site_root, href) : resolve(dirname(built.built_path), href);
|
|
378
|
+
if (!existsSync(file)) {
|
|
379
|
+
problems.push(`${label}: ${builtRel} links ${hrefs[brand]}, which is not in the built output.`);
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
layers.push(file);
|
|
383
|
+
styled.push(label);
|
|
384
|
+
evidence.push(`${label}: ${builtRel} loads ${relative(derived.target_repo, file)} after ${CORE_STYLESHEET}.`);
|
|
385
|
+
}
|
|
386
|
+
if (problems.length) throw refuseRecord("theme", problems);
|
|
387
|
+
if (!styled.length) {
|
|
388
|
+
const outOfScope = commerce.out_of_scope.map((page) => page.page_id || page.route || page.type);
|
|
389
|
+
throw refuseRecord("theme", [!commerce.built.length
|
|
390
|
+
? `No commerce page is built in this scope (declared but not built: ${outOfScope.join(", ")}); build them, run ${cmd("record")} build, then record theme.`
|
|
391
|
+
: `No built commerce page loads ${CORE_STYLESHEET} (${unstyled.join(", ")}${outOfScope.length ? `; declared but not built: ${outOfScope.join(", ")}` : ""}), so no page renders family components for a brand layer to style. If shipping without one is intended, record that with ${cmd("theme")} waive.`]);
|
|
392
|
+
}
|
|
393
|
+
const cssPath = relative(derived.target_repo, layers[0]);
|
|
394
|
+
const generated = resolve(derived.target_repo, ".campaign-runtime/theme/brand-theme.css");
|
|
395
|
+
if (existsSync(generated)) {
|
|
396
|
+
const same = readFileSync(generated, "utf8") === readFileSync(layers[0], "utf8");
|
|
397
|
+
evidence.push(`${cssPath} ${same ? "matches" : "differs from"} the generated .campaign-runtime/theme/brand-theme.css.`);
|
|
398
|
+
}
|
|
399
|
+
return {
|
|
400
|
+
cssPath,
|
|
401
|
+
commercePages: styled,
|
|
402
|
+
outOfScope: commerce.out_of_scope.map((page) => page.page_id || page.route || page.type),
|
|
403
|
+
evidence,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// An applied brand layer replaces any earlier waiver: the gate reads a waiver
|
|
408
|
+
// first, and the two answer the same question opposite ways. Other fields
|
|
409
|
+
// stay: `warnings` come from theme inspect, and `repair_loop_defect` is what
|
|
410
|
+
// `record polish` recorded about the repair loop, history the gate never reads.
|
|
411
|
+
function composeTheme(report, { now, recordedBy, layer }) {
|
|
412
|
+
const theme = {
|
|
413
|
+
...(isObject(report.theme) ? report.theme : {}),
|
|
414
|
+
status: "applied",
|
|
415
|
+
css_path: layer.cssPath,
|
|
416
|
+
load_order: "after-next-core",
|
|
417
|
+
commerce_pages: layer.commercePages,
|
|
418
|
+
evidence: layer.evidence,
|
|
419
|
+
waiver: null,
|
|
420
|
+
recorded_by: recordedBy,
|
|
421
|
+
recorded_at: now,
|
|
422
|
+
};
|
|
423
|
+
return { report: { ...report, theme }, context: null };
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// The local preview a `record deploy` URL must name: a loopback http(s)
|
|
427
|
+
// origin serving the packet's route root ("/<slug>/", or "/" for a
|
|
428
|
+
// root-served campaign) of a local-serve packet. Returns the URL as recorded
|
|
429
|
+
// (origin plus route root) and the problems that refuse it.
|
|
430
|
+
function localPreviewUrl(packet, rawUrl) {
|
|
431
|
+
if (!isLocalServePacket(packet)) {
|
|
432
|
+
return { url: null, problems: [`record deploy records a local preview, and this packet's deploy.target is "${packet?.deploy?.target || "unset"}". Serve the build locally with deploy.target local-serve (${cmd("qa")} policy set --packet <p> --deploy-target local-serve), or record a hosted deploy on the packet and stages.deploy.`] };
|
|
433
|
+
}
|
|
434
|
+
let url;
|
|
435
|
+
try {
|
|
436
|
+
url = new URL(String(rawUrl));
|
|
437
|
+
} catch {
|
|
438
|
+
return { url: null, problems: [`--base-url ${JSON.stringify(rawUrl)} is not a URL; give the served address, for example http://localhost:<port>/<slug>/.`] };
|
|
439
|
+
}
|
|
440
|
+
const problems = [];
|
|
441
|
+
if (!/^https?:$/.test(url.protocol)) problems.push(`--base-url must be http or https (got ${url.protocol}).`);
|
|
442
|
+
if (!isLoopbackHostname(url.hostname)) problems.push(`--base-url ${url.href} is not a loopback address; a local preview is served on localhost, 127.0.0.1 or [::1].`);
|
|
443
|
+
const routeRoot = campaignRouteRoot(packet);
|
|
444
|
+
if (!routeRoot) {
|
|
445
|
+
problems.push("The packet records no campaign.public_route_slug, so the served route root is unknown; record it first.");
|
|
446
|
+
} else if (`/${routeKey(url.pathname.replace(/\/index\.html?$/i, "/"))}/`.replace("//", "/") !== routeRoot) {
|
|
447
|
+
problems.push(`--base-url ${url.href} serves ${url.pathname}, but this campaign's route root is ${routeRoot}; give ${url.origin}${routeRoot}.`);
|
|
448
|
+
}
|
|
449
|
+
return { url: problems.length ? null : `${url.origin}${routeRoot}`, problems };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const PROBE_TIMEOUT_MS = 5_000;
|
|
453
|
+
const PROBE_MAX_REDIRECTS = 3;
|
|
454
|
+
|
|
455
|
+
// One page request. Redirects are followed only within the preview's own
|
|
456
|
+
// origin, so a probe never leaves the machine; a redirect elsewhere is
|
|
457
|
+
// reported, not followed.
|
|
458
|
+
async function probePage(target, fetchImpl) {
|
|
459
|
+
let current = target;
|
|
460
|
+
try {
|
|
461
|
+
for (let hop = 0; hop <= PROBE_MAX_REDIRECTS; hop += 1) {
|
|
462
|
+
const response = await fetchImpl(current, { redirect: "manual", signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
|
|
463
|
+
await response.body?.cancel();
|
|
464
|
+
const location = response.headers?.get?.("location");
|
|
465
|
+
if (!(response.status >= 300 && response.status < 400) || !location) return { url: target, status: response.status };
|
|
466
|
+
const next = new URL(location, current);
|
|
467
|
+
if (next.origin !== new URL(target).origin) return { url: target, status: null, error: `redirects to ${next.href}, off this preview; nothing was requested there` };
|
|
468
|
+
current = next.href;
|
|
469
|
+
}
|
|
470
|
+
return { url: target, status: null, error: `more than ${PROBE_MAX_REDIRECTS} redirects` };
|
|
471
|
+
} catch (error) {
|
|
472
|
+
const reason = error?.name === "TimeoutError" ? `no answer within ${PROBE_TIMEOUT_MS / 1000} s` : String(error?.cause?.code || error?.message || error);
|
|
473
|
+
// A local static server usually speaks plain http; say so when https fails.
|
|
474
|
+
return { url: target, status: null, error: new URL(target).protocol === "https:" ? `${reason} (over https; a local static server usually serves http)` : reason };
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* What only the running server can say, read before the target lock (like
|
|
480
|
+
* the polish --evidence file): the URL names this campaign's local preview,
|
|
481
|
+
* and every built page under it answers 2xx. The lock re-checks the
|
|
482
|
+
* packet and the built output; the probe is never trusted for either.
|
|
483
|
+
*/
|
|
484
|
+
export async function probeLocalPreview({ packetPath, baseUrl, fetchImpl = globalThis.fetch }) {
|
|
485
|
+
const packet = readPacketFile("deploy", packetPath);
|
|
486
|
+
const { url, problems } = localPreviewUrl(packet, baseUrl);
|
|
487
|
+
if (problems.length) throw refuseRecord("deploy", problems);
|
|
488
|
+
const site = resolveBuiltSiteScope(targetRepoFor(packetPath, packet), { slug: packet.campaign.public_route_slug });
|
|
489
|
+
if (!site.ok) throw refuseRecord("deploy", [site.error]);
|
|
490
|
+
// The built pages, not the bare route root: a funnel often has no index
|
|
491
|
+
// page there, and servers answer that differently (404, a listing).
|
|
492
|
+
const targets = [...new Set(site.pages.map((page) => (routeKey(page.route) ? new URL(`${routeKey(page.route)}/`, url).href : url)))];
|
|
493
|
+
// Requested together; the evidence keeps the built pages' order.
|
|
494
|
+
const routes = await Promise.all(targets.map((target) => probePage(target, fetchImpl)));
|
|
495
|
+
const failed = routes.filter((route) => !(route.status >= 200 && route.status < 300));
|
|
496
|
+
if (failed.length) {
|
|
497
|
+
throw refuseRecord("deploy", [
|
|
498
|
+
...failed.map((route) => `${route.url}: ${route.status ? `HTTP ${route.status}` : route.error}.`),
|
|
499
|
+
`Serve the current _site/ build so every page answers at ${url}, then run ${cmd("record")} deploy again.`,
|
|
500
|
+
]);
|
|
501
|
+
}
|
|
502
|
+
return { url, routes };
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// A recorded local preview: the packet's deploy.preview_url, and stages.deploy
|
|
506
|
+
// completed with the URL in outputs (what next reads) and the probe as evidence.
|
|
507
|
+
function composeDeploy(report, packet, { now, recordedBy, probe }) {
|
|
508
|
+
const deploy = {
|
|
509
|
+
...stageObject(report, "deploy"),
|
|
510
|
+
stage: "deploy",
|
|
511
|
+
status: "completed",
|
|
512
|
+
outputs: [probe.url],
|
|
513
|
+
evidence: probe.routes.map((route) => `${route.url} answered HTTP ${route.status}`),
|
|
514
|
+
completed_at: now,
|
|
515
|
+
recorded_by: recordedBy,
|
|
516
|
+
blockers: [],
|
|
517
|
+
};
|
|
518
|
+
return {
|
|
519
|
+
report: { ...report, stages: { ...report.stages, deploy } },
|
|
520
|
+
context: null,
|
|
521
|
+
packet: { ...packet, deploy: { ...packet.deploy, preview_url: probe.url } },
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
297
525
|
// Every check a written record must pass, over exactly what would be written.
|
|
298
526
|
function validateRecord(stage, { report, context, packet, fingerprint }) {
|
|
299
527
|
const problems = [
|
|
@@ -369,7 +597,8 @@ function doctorFacts(stage, doctor, report, packet) {
|
|
|
369
597
|
const derived = doctor.derived || {};
|
|
370
598
|
const binding = bindingProblems(doctor, report, packet);
|
|
371
599
|
if (binding.length) throw refuseRecord(stage, binding);
|
|
372
|
-
|
|
600
|
+
// The brand layer is applied to built output, so theme is checked as polish is.
|
|
601
|
+
const ladder = ladderProblems(stage === "theme" ? "polish" : stage, doctor, report);
|
|
373
602
|
if (ladder.length) throw refuseRecord(stage, ladder);
|
|
374
603
|
if (stage === "setup") {
|
|
375
604
|
const outputDir = optionalString(derived.target_output_dir);
|
|
@@ -386,13 +615,15 @@ function doctorFacts(stage, doctor, report, packet) {
|
|
|
386
615
|
if (stage === "build" && derived.scaffold_required === true) {
|
|
387
616
|
throw refuseRecord(stage, [`Setup is still required (${derived.scaffold_reason || "Build Context scaffold.required is true"}); run ${cmd("record")} setup first.`]);
|
|
388
617
|
}
|
|
389
|
-
if (stage === "polish") {
|
|
618
|
+
if (stage === "polish" || stage === "theme" || stage === "deploy") {
|
|
390
619
|
const recorded = optionalString(report?.stages?.assembly?.build_fingerprint);
|
|
391
620
|
if (!String(report?.stages?.assembly?.status || "").startsWith("completed") || !recorded) {
|
|
392
621
|
throw refuseRecord(stage, [`Build is not recorded (stages.assembly needs a completed status and build_fingerprint); run ${cmd("record")} build first.`]);
|
|
393
622
|
}
|
|
394
623
|
if (recorded !== fingerprint) {
|
|
395
|
-
throw refuseRecord(stage, [
|
|
624
|
+
throw refuseRecord(stage, [stage === "theme" || stage === "deploy"
|
|
625
|
+
? `The built output changed since build was recorded (recorded ${recorded}, current ${fingerprint}); run ${cmd("record")} build, then record ${stage} again.`
|
|
626
|
+
: `The built output changed since build was recorded (recorded ${recorded}, current ${fingerprint}); run ${cmd("record")} build, then ${cmd("polish")} capture, then record polish again.`]);
|
|
396
627
|
}
|
|
397
628
|
}
|
|
398
629
|
return {
|
|
@@ -416,6 +647,37 @@ function assertOutputUnchanged(stage, facts) {
|
|
|
416
647
|
}
|
|
417
648
|
}
|
|
418
649
|
|
|
650
|
+
// Under the lock, the probe is checked against the packet as it is now (it
|
|
651
|
+
// could have been retargeted while the probe ran), and the theme gate, which
|
|
652
|
+
// blocks deploy, must not be blocked.
|
|
653
|
+
function deployFacts(doctor, packet, probe) {
|
|
654
|
+
const { url, problems } = localPreviewUrl(packet, probe.url);
|
|
655
|
+
if (problems.length) throw refuseRecord("deploy", problems);
|
|
656
|
+
if (url !== probe.url) {
|
|
657
|
+
throw refuseRecord("deploy", [`The packet's route root changed while the preview was probed (probed ${probe.url}, now ${url}); run ${cmd("record")} deploy again.`]);
|
|
658
|
+
}
|
|
659
|
+
const gate = doctor.derived?.theme_gate;
|
|
660
|
+
if (gate?.status === "blocked") {
|
|
661
|
+
throw refuseRecord("deploy", [
|
|
662
|
+
`${gate.code}: ${gate.reason}`,
|
|
663
|
+
...(gate.required_actions || []).map((action) => `required action: ${action.command || action.description}`),
|
|
664
|
+
]);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* The `record` command as the CLI runs it: `record deploy` first probes the
|
|
670
|
+
* served preview (asynchronously, before the target lock), then every kind
|
|
671
|
+
* records through recordStageCommand.
|
|
672
|
+
*/
|
|
673
|
+
export async function recordCommand(args, options = {}) {
|
|
674
|
+
const { stage, packetPath, baseUrl } = parseRecordArgs(args);
|
|
675
|
+
const probe = stage === "deploy" && existsSync(packetPath)
|
|
676
|
+
? await probeLocalPreview({ packetPath, baseUrl, ...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}) })
|
|
677
|
+
: null;
|
|
678
|
+
return recordStageCommand(args, { ...options, probe });
|
|
679
|
+
}
|
|
680
|
+
|
|
419
681
|
function readPacketFile(stage, packetPath) {
|
|
420
682
|
try {
|
|
421
683
|
return JSON.parse(readFileSync(packetPath, "utf8"));
|
|
@@ -434,11 +696,13 @@ function readPacketFile(stage, packetPath) {
|
|
|
434
696
|
* target lock, after doctor has read the target and before anything is
|
|
435
697
|
* composed or written.
|
|
436
698
|
*/
|
|
437
|
-
export function recordStageCommand(args, { now = () => new Date(), beforeLock = null, afterDoctorRead = null } = {}) {
|
|
699
|
+
export function recordStageCommand(args, { now = () => new Date(), beforeLock = null, afterDoctorRead = null, probe = null } = {}) {
|
|
438
700
|
const { stage, packetPath, evidencePath, dryRun } = parseRecordArgs(args);
|
|
439
701
|
if (!existsSync(packetPath)) throw new Error(`record ${stage}: Build Packet not found at ${packetPath}; run ${cmd("start")} or ${cmd("prepare-build")} first.`);
|
|
702
|
+
if (stage === "deploy" && !probe) throw new Error("record deploy needs the served-route probe; run it through recordCommand.");
|
|
440
703
|
// Operator input, not target state: no campaigns-os writer produces it.
|
|
441
|
-
|
|
704
|
+
// For deploy, the probe of the running server (probeLocalPreview).
|
|
705
|
+
const input = stage === "polish" ? readPolishEvidenceFile(evidencePath) : stage === "deploy" ? probe : null;
|
|
442
706
|
const sidecars = {
|
|
443
707
|
contextPath: args.context ? resolve(args.context) : undefined,
|
|
444
708
|
reportPath: args.report ? resolve(args.report) : undefined,
|
|
@@ -457,11 +721,18 @@ export function recordStageCommand(args, { now = () => new Date(), beforeLock =
|
|
|
457
721
|
stage, packetPath, sidecars, lockedTarget, input, dryRun, timestamp, recordedBy, afterDoctorRead,
|
|
458
722
|
});
|
|
459
723
|
const recorded = dryRun ? run() : withTargetLockSync(lockedTarget, run, { command: `record ${stage}` });
|
|
460
|
-
const { composed, facts, reportPath, contextPath, after } = recorded;
|
|
724
|
+
const { composed, facts, layer, reportPath, contextPath, after } = recorded;
|
|
461
725
|
|
|
462
726
|
const stageKey = stage === "build" ? "assembly" : stage;
|
|
463
|
-
const writes = [...(composed.context ? [contextPath] : []), reportPath];
|
|
464
|
-
const ready = [
|
|
727
|
+
const writes = [...(composed.context ? [contextPath] : []), ...(composed.packet ? [packetPath] : []), reportPath];
|
|
728
|
+
const ready = stage === "deploy" ? [
|
|
729
|
+
`stages.deploy.status = completed; deploy.preview_url = ${input.url}`,
|
|
730
|
+
...composed.report.stages.deploy.evidence,
|
|
731
|
+
] : stage === "theme" ? [
|
|
732
|
+
`theme.status = applied, load_order = after-next-core (${layer.cssPath})`,
|
|
733
|
+
...layer.evidence,
|
|
734
|
+
...(layer.outOfScope.length ? [`Not built in this scope, so not checked: ${layer.outOfScope.join(", ")}; run record theme again after building them.`] : []),
|
|
735
|
+
] : [
|
|
465
736
|
`stages.${stageKey}.status = ${composed.report.stages[stageKey].status}`,
|
|
466
737
|
...(facts.fingerprint ? [`build output fingerprint ${facts.fingerprint} (doctor derived.build_output_fingerprint.value)`] : []),
|
|
467
738
|
...(stage === "build" ? [`stages.polish.status = ${composed.report.stages.polish.status}`] : []),
|
|
@@ -477,7 +748,7 @@ export function recordStageCommand(args, { now = () => new Date(), beforeLock =
|
|
|
477
748
|
...(composed.context ? { context_path: contextPath } : {}),
|
|
478
749
|
...(dryRun ? { would_write: writes } : { written: writes }),
|
|
479
750
|
build_fingerprint: facts.fingerprint || null,
|
|
480
|
-
record: composed.report.stages[stageKey],
|
|
751
|
+
record: stage === "theme" ? composed.report.theme : composed.report.stages[stageKey],
|
|
481
752
|
...(stage === "build" ? { polish: composed.report.stages.polish } : {}),
|
|
482
753
|
...(composed.context ? { scaffold: composed.context.scaffold } : {}),
|
|
483
754
|
...(stage === "polish" && input.hasRepairLoopDefect ? { repair_loop_defect: input.repairLoopDefect } : {}),
|
|
@@ -508,6 +779,7 @@ function recordUnderLock({ stage, packetPath, sidecars, lockedTarget, input, dry
|
|
|
508
779
|
|
|
509
780
|
let composed = null;
|
|
510
781
|
let facts = null;
|
|
782
|
+
let layer = null;
|
|
511
783
|
const compose = (report) => {
|
|
512
784
|
if (!isObject(report) || !isObject(report.stages)) throw refuseRecord(stage, [`Assembly Report at ${reportPath} has no stages object.`]);
|
|
513
785
|
const context = stage === "setup" ? readJsonIfExists(contextPath) : null;
|
|
@@ -525,27 +797,36 @@ function recordUnderLock({ stage, packetPath, sidecars, lockedTarget, input, dry
|
|
|
525
797
|
}
|
|
526
798
|
facts = doctorFacts(stage, doctor, report, packet);
|
|
527
799
|
if (typeof afterDoctorRead === "function") afterDoctorRead();
|
|
800
|
+
if (stage === "theme") layer = brandLayerFacts(doctor);
|
|
801
|
+
if (stage === "deploy") deployFacts(doctor, packet, input);
|
|
528
802
|
const next = stage === "setup"
|
|
529
803
|
? composeSetup(report, context, { now: timestamp, recordedBy })
|
|
530
804
|
: stage === "build"
|
|
531
805
|
? composeBuild(report, { now: timestamp, recordedBy, fingerprint: facts.fingerprint })
|
|
532
|
-
:
|
|
806
|
+
: stage === "theme"
|
|
807
|
+
? composeTheme(report, { now: timestamp, recordedBy, layer })
|
|
808
|
+
: stage === "deploy"
|
|
809
|
+
? composeDeploy(report, packet, { now: timestamp, recordedBy, probe: input })
|
|
810
|
+
: composePolish(report, { now: timestamp, recordedBy, fingerprint: facts.fingerprint, input });
|
|
533
811
|
applyDerivedAssemblyReportSummary(next.report);
|
|
812
|
+
// The packet's one new value, deploy.preview_url, is checked by
|
|
813
|
+
// localPreviewUrl; the rest of the packet is as the operator left it.
|
|
534
814
|
validateRecord(stage, { report: next.report, context: next.context, packet, fingerprint: facts.fingerprint });
|
|
535
815
|
assertOutputUnchanged(stage, facts);
|
|
536
816
|
composed = next;
|
|
537
817
|
if (dryRun) return null;
|
|
538
818
|
// Written inside the report's critical section, after every check and
|
|
539
|
-
// before the report itself, so the
|
|
819
|
+
// before the report itself, so the files move together.
|
|
540
820
|
if (next.context) writeJsonAtomic(contextPath, next.context);
|
|
821
|
+
if (next.packet) writeJsonAtomic(packetPath, next.packet);
|
|
541
822
|
return next.report;
|
|
542
823
|
};
|
|
543
824
|
// Already inside the target lock, which commitAssemblyReport re-enters.
|
|
544
825
|
commitAssemblyReport(workspace, compose, {
|
|
545
826
|
command: `record ${stage}`,
|
|
546
|
-
staleReason: `stages.${stage === "build" ? "assembly" : stage} was recorded after this doctor snapshot. Re-run ${cmd("doctor")} (or next) for current state.`,
|
|
827
|
+
staleReason: `${stage === "theme" ? "theme" : `stages.${stage === "build" ? "assembly" : stage}`} was recorded after this doctor snapshot. Re-run ${cmd("doctor")} (or next) for current state.`,
|
|
547
828
|
...(dryRun ? { lock: false } : {}),
|
|
548
829
|
});
|
|
549
830
|
const after = dryRun ? null : doctorPacket(packetPath, sidecars);
|
|
550
|
-
return { composed, facts, reportPath, contextPath, after };
|
|
831
|
+
return { composed, facts, layer, reportPath, contextPath, after };
|
|
551
832
|
}
|
package/src/theme-gate.mjs
CHANGED
|
@@ -123,7 +123,7 @@ export function evaluateThemeGate({ reportTheme = null, contextTheme = null, sco
|
|
|
123
123
|
id: "fix_load_order",
|
|
124
124
|
kind: "manual",
|
|
125
125
|
command: null,
|
|
126
|
-
description:
|
|
126
|
+
description: `List brand-theme.css after next-core.css in commerce-page frontmatter styles, rebuild, run campaigns-os record build, then campaigns-os record theme --packet ${packetArg}.`,
|
|
127
127
|
},
|
|
128
128
|
],
|
|
129
129
|
);
|
|
@@ -145,7 +145,7 @@ export function evaluateThemeGate({ reportTheme = null, contextTheme = null, sco
|
|
|
145
145
|
return result(
|
|
146
146
|
"blocked",
|
|
147
147
|
"theme_gate.needs_decision",
|
|
148
|
-
`Assembly report theme status is "${reportStatus || "missing"}" and no generatable brand theme exists;
|
|
148
|
+
`Assembly report theme status is "${reportStatus || "missing"}" and no generatable brand theme exists; apply a brand layer and record it with campaigns-os record theme, or waive the gate with a reason.`,
|
|
149
149
|
[
|
|
150
150
|
{
|
|
151
151
|
id: "waive_theme",
|
|
@@ -172,7 +172,7 @@ export function evaluateThemeGate({ reportTheme = null, contextTheme = null, sco
|
|
|
172
172
|
id: "apply_brand_layer",
|
|
173
173
|
kind: "manual",
|
|
174
174
|
command: null,
|
|
175
|
-
description:
|
|
175
|
+
description: `Copy brand-theme.css into the campaign assets/css folder, list it after next-core.css in the frontmatter styles of the commerce pages that render family components, rebuild, run campaigns-os record build, then campaigns-os record theme --packet ${packetArg}, which records report.theme from the built pages.`,
|
|
176
176
|
},
|
|
177
177
|
{
|
|
178
178
|
id: "waive_theme",
|