@nextcommerce/campaigns-os 1.48.0 → 1.52.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 +539 -0
- package/agents/claude/CLAUDE.md +6 -5
- package/agents/codex/AGENTS.md +6 -5
- package/agents/copilot/copilot-instructions.md +3 -3
- package/agents/cursor/campaigns-os.mdc +3 -3
- package/campaign-spec/dist/rules/campaign-metadata.d.ts +5 -1
- package/campaign-spec/dist/rules/campaign-metadata.js +9 -2
- package/campaign-spec/dist/rules/design-source-shape.js +13 -3
- package/campaign-spec/dist/rules/sdk-version.js +2 -1
- package/compatibility.json +1 -1
- package/contracts/commerce-surface-catalog.json +26 -46
- package/contracts/effects.v1.json +254 -2
- package/contracts/release-ledger.json +1239 -0
- package/contracts/supported-surface.json +4 -4
- package/contracts/template-brand-contract.shared-commerce.v0.json +2 -2
- package/contracts/template-slot-manifest.shared-content-core.v0.json +24 -0
- package/docs/brand-theme-bridge.md +12 -6
- package/docs/build-packet.md +101 -12
- package/docs/campaign-build-brief.md +25 -1
- package/docs/effects.md +6 -0
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/polish-evidence.md +10 -0
- package/docs/qa-and-test-orders.md +66 -11
- package/docs/runtime-readiness.md +1 -1
- package/docs/sdk-storage-compatibility.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 +3 -3
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +4 -4
- package/skills/next-campaigns-os/SKILL.md +4 -4
- package/skills/next-campaigns-os/references/session-intake.md +7 -3
- package/skills/next-campaigns-os-setup/SKILL.md +3 -3
- package/skills/next-campaigns-polish/SKILL.md +5 -4
- package/skills/next-campaigns-qa/SKILL.md +6 -5
- package/skills.json +10 -10
- package/src/adapter-decision-contract.mjs +1 -1
- package/src/brand-theme.mjs +25 -2
- package/src/build-brief.mjs +68 -21
- package/src/built-site-scope.mjs +39 -6
- package/src/built-smoke-qc.mjs +1117 -0
- package/src/campaign-identity.mjs +36 -2
- package/src/cart-placeholders.mjs +730 -0
- package/src/cli.mjs +320 -42
- package/src/commercial-journey.mjs +65 -4
- package/src/commercial-parity.mjs +6 -1
- package/src/doctor/checks.mjs +291 -24
- package/src/doctor/inspect.mjs +53 -2
- package/src/doctor/next-step.mjs +1 -1
- package/src/install-mode.mjs +0 -8
- package/src/invocation.mjs +5 -2
- package/src/local-preview-policy.mjs +1 -1
- package/src/local-proof.mjs +4 -1
- package/src/polish-browser.mjs +218 -1
- package/src/polish-capture.mjs +1 -1
- package/src/polish-media-weight.mjs +492 -0
- package/src/polish-node.mjs +96 -4
- package/src/progress-node.mjs +5 -1
- package/src/qa-binding-evidence.mjs +21 -0
- package/src/qa-browser.mjs +338 -97
- package/src/qa-content-params.mjs +889 -0
- package/src/qa-node.mjs +114 -14
- package/src/qa-order-bump.mjs +22 -1
- package/src/qa-policy-links.mjs +1019 -0
- package/src/qa-tracking-params.mjs +1389 -0
- package/src/qa-url-privacy.mjs +168 -0
- package/src/qc-accept.mjs +446 -0
- package/src/qc-check-registry.mjs +83 -0
- package/src/qc-results.mjs +1049 -0
- package/src/sdk-attribute-index.mjs +71 -0
- package/src/sdk-markup.mjs +2 -2
- package/src/sdk-storage-compatibility.mjs +63 -3
- package/src/source-prep.mjs +37 -7
- package/src/stage-record.mjs +356 -36
- package/src/theme-gate.mjs +3 -3
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,25 @@
|
|
|
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 { LOCAL_PROOF_BUILD_ENVIRONMENT, LOCAL_PROOF_PRODUCTION_ENVIRONMENT, isLocalServePacket } from "./local-proof.mjs";
|
|
33
|
+
import { CARRIED_FORWARD } from "./local-preview-policy.mjs";
|
|
34
|
+
import { isLoopbackHostname } from "./remit.mjs";
|
|
35
|
+
import { campaignRouteRoot } from "./route-identity.mjs";
|
|
27
36
|
import { writeJsonAtomic } from "./doctor-sidecar.mjs";
|
|
28
37
|
import { doctorPacket } from "./doctor/inspect.mjs";
|
|
29
38
|
import { validateAssemblyReport } from "./doctor/checks.mjs";
|
|
@@ -39,13 +48,21 @@ import {
|
|
|
39
48
|
import { evaluateRecordedHiddenEagerMediaCheckpoint } from "./polish-node.mjs";
|
|
40
49
|
import { applyDerivedAssemblyReportSummary, assemblyReportMatchesPacket, commitAssemblyReport } from "./stage-ledger.mjs";
|
|
41
50
|
import { withTargetLockSync } from "./target-lock.mjs";
|
|
51
|
+
import { commerceScopeFromScope } from "./theme-gate.mjs";
|
|
42
52
|
|
|
43
|
-
export const RECORD_STAGES = Object.freeze(["setup", "build", "polish"]);
|
|
53
|
+
export const RECORD_STAGES = Object.freeze(["setup", "build", "polish", "theme", "deploy"]);
|
|
44
54
|
|
|
45
|
-
// Every flag `record` reads, plus the
|
|
46
|
-
// lifecycle journal
|
|
47
|
-
|
|
55
|
+
// Every flag `record` reads, plus the three any command accepts (run id,
|
|
56
|
+
// lifecycle journal, and the deviation reason the deviation notice asks
|
|
57
|
+
// agents to declare). Anything else is refused before a file is read.
|
|
58
|
+
const RECORD_FLAGS = Object.freeze(["packet", "context", "report", "dry-run", "json", "run-id", "lifecycle-journal", "deviation-reason"]);
|
|
48
59
|
const POLISH_RECORD_FLAGS = Object.freeze(["evidence"]);
|
|
60
|
+
const DEPLOY_RECORD_FLAGS = Object.freeze(["base-url"]);
|
|
61
|
+
const BUILD_RECORD_FLAGS = Object.freeze(["build-environment"]);
|
|
62
|
+
// The page-kit environment the built output was rendered in, recorded on
|
|
63
|
+
// stages.assembly.evidence.build_environment (local proof mode builds in
|
|
64
|
+
// development; doctor and page-kit parity read it).
|
|
65
|
+
export const BUILD_ENVIRONMENTS = Object.freeze([LOCAL_PROOF_BUILD_ENVIRONMENT, LOCAL_PROOF_PRODUCTION_ENVIRONMENT]);
|
|
49
66
|
|
|
50
67
|
// The keys a --evidence file may carry. `evidence` is stages.polish.evidence;
|
|
51
68
|
// `repair_loop_defect` is report.theme.repair_loop_defect; `blockers` (status
|
|
@@ -64,6 +81,13 @@ function schemaValidator(file) {
|
|
|
64
81
|
return validators.get(file);
|
|
65
82
|
}
|
|
66
83
|
|
|
84
|
+
// The value at an Ajv instancePath (JSON Pointer) in the validated document.
|
|
85
|
+
function valueAt(document, pointer) {
|
|
86
|
+
return pointer.split("/").slice(1)
|
|
87
|
+
.map((part) => part.replace(/~1/g, "/").replace(/~0/g, "~"))
|
|
88
|
+
.reduce((node, key) => (node == null ? undefined : node[key]), document);
|
|
89
|
+
}
|
|
90
|
+
|
|
67
91
|
// Ajv's instancePath (`/theme/repair_loop_defect`) as the dotted field name
|
|
68
92
|
// the rest of the toolkit prints (`theme.repair_loop_defect`).
|
|
69
93
|
function schemaProblems(file, value, label) {
|
|
@@ -73,7 +97,11 @@ function schemaProblems(file, value, label) {
|
|
|
73
97
|
const problems = [];
|
|
74
98
|
for (const error of validate.errors || []) {
|
|
75
99
|
const field = error.instancePath.split("/").filter(Boolean).join(".") || "(root)";
|
|
76
|
-
|
|
100
|
+
// Ajv's enum message names no values; the allowed list is the remedy.
|
|
101
|
+
const allowed = error.keyword === "enum" && Array.isArray(error.params?.allowedValues)
|
|
102
|
+
? `: ${error.params.allowedValues.map((allowedValue) => JSON.stringify(allowedValue)).join(", ")} (got ${JSON.stringify(valueAt(value, error.instancePath))})`
|
|
103
|
+
: "";
|
|
104
|
+
const line = `${label} ${field} ${error.message}${allowed}`;
|
|
77
105
|
if (seen.has(line)) continue;
|
|
78
106
|
seen.add(line);
|
|
79
107
|
problems.push(line);
|
|
@@ -86,6 +114,11 @@ function typeName(value) {
|
|
|
86
114
|
return Array.isArray(value) ? "array" : typeof value;
|
|
87
115
|
}
|
|
88
116
|
|
|
117
|
+
// visual_review keys only `polish capture` writes. `record polish --evidence`
|
|
118
|
+
// refuses them by name and carries the captured values forward.
|
|
119
|
+
export const PACKAGE_OWNED_VISUAL_REVIEW_KEYS = Object.freeze(["page_load", "media_weight"]);
|
|
120
|
+
export const PACKAGE_OWNED_KEY_REFUSAL = "package_owned_key";
|
|
121
|
+
|
|
89
122
|
function refuseRecord(stage, problems) {
|
|
90
123
|
return new Error(`record ${stage} refused; nothing was written:\n${problems.map((problem) => `- ${problem}`).join("\n")}`);
|
|
91
124
|
}
|
|
@@ -93,25 +126,31 @@ function refuseRecord(stage, problems) {
|
|
|
93
126
|
export function parseRecordArgs(args) {
|
|
94
127
|
const stage = args._[1];
|
|
95
128
|
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
|
|
129
|
+
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>, record deploy --base-url <served url>, and record build [--build-environment <${BUILD_ENVIRONMENTS.join("|")}>].`);
|
|
97
130
|
}
|
|
98
|
-
const known = new Set([...RECORD_FLAGS, ...(stage === "polish" ? POLISH_RECORD_FLAGS : [])]);
|
|
131
|
+
const known = new Set([...RECORD_FLAGS, ...(stage === "polish" ? POLISH_RECORD_FLAGS : []), ...(stage === "deploy" ? DEPLOY_RECORD_FLAGS : []), ...(stage === "build" ? BUILD_RECORD_FLAGS : [])]);
|
|
99
132
|
const unknown = Object.keys(args).filter((key) => key !== "_" && !known.has(key));
|
|
100
133
|
if (unknown.length) {
|
|
101
134
|
throw refused(`Unknown flag${unknown.length > 1 ? "s" : ""} for record ${stage}: ${unknown.map((key) => `--${key}`).join(", ")}. Known flags: ${[...known].map((key) => `--${key}`).join(", ")}.`);
|
|
102
135
|
}
|
|
103
|
-
for (const flag of ["context", "report", "run-id", "lifecycle-journal"]) {
|
|
136
|
+
for (const flag of ["context", "report", "run-id", "lifecycle-journal", "deviation-reason"]) {
|
|
104
137
|
if (Object.hasOwn(args, flag)) requireArg(args, flag);
|
|
105
138
|
}
|
|
106
139
|
if (Object.hasOwn(args, "dry-run") && args["dry-run"] !== true) {
|
|
107
140
|
throw refused(`--dry-run takes no value (got ${JSON.stringify(args["dry-run"])}); write \`--dry-run\` on its own, after the other flags.`);
|
|
108
141
|
}
|
|
109
142
|
if (Object.hasOwn(args, "json") && args.json !== true) throw refused("--json is a boolean flag and takes no value.");
|
|
143
|
+
const buildEnvironment = Object.hasOwn(args, "build-environment") ? args["build-environment"] : null;
|
|
144
|
+
if (buildEnvironment !== null && !BUILD_ENVIRONMENTS.includes(buildEnvironment)) {
|
|
145
|
+
throw refused(`--build-environment must be one of: ${BUILD_ENVIRONMENTS.join(", ")} (got ${JSON.stringify(buildEnvironment)}).`);
|
|
146
|
+
}
|
|
110
147
|
return {
|
|
111
148
|
stage,
|
|
112
149
|
packetPath: resolve(requireArg(args, "packet")),
|
|
113
150
|
evidencePath: stage === "polish" ? resolve(requireArg(args, "evidence")) : null,
|
|
151
|
+
baseUrl: stage === "deploy" ? requireArg(args, "base-url") : null,
|
|
114
152
|
dryRun: args["dry-run"] === true,
|
|
153
|
+
buildEnvironment,
|
|
115
154
|
};
|
|
116
155
|
}
|
|
117
156
|
|
|
@@ -170,8 +209,13 @@ export function readPolishEvidenceFile(path) {
|
|
|
170
209
|
}
|
|
171
210
|
if (evidence.visual_review !== undefined && !isObject(evidence.visual_review)) {
|
|
172
211
|
problems.push(`evidence.visual_review must be an object with a screenshots array (got ${typeName(evidence.visual_review)}).`);
|
|
173
|
-
} else if (isObject(evidence.visual_review)
|
|
174
|
-
|
|
212
|
+
} else if (isObject(evidence.visual_review)) {
|
|
213
|
+
// Package-owned keys are refused by name and listed first, so the
|
|
214
|
+
// refusal leads with its code.
|
|
215
|
+
const owned = PACKAGE_OWNED_VISUAL_REVIEW_KEYS.filter((key) => Object.hasOwn(evidence.visual_review, key));
|
|
216
|
+
if (owned.length) {
|
|
217
|
+
problems.unshift(`${PACKAGE_OWNED_KEY_REFUSAL}: ${owned.map((key) => `evidence.visual_review.${key}`).join(" and ")} ${owned.length === 1 ? "is" : "are"} written only by ${cmd("polish")} capture; remove ${owned.length === 1 ? "it" : "them"} from the file (the captured value on the report is kept).`);
|
|
218
|
+
}
|
|
175
219
|
}
|
|
176
220
|
}
|
|
177
221
|
if (Object.hasOwn(input, "repair_loop_defect") && input.repair_loop_defect !== null && !isObject(input.repair_loop_defect)) {
|
|
@@ -224,10 +268,12 @@ function composeSetup(report, context, { now, recordedBy }) {
|
|
|
224
268
|
return { report: nextReport, context: nextContext };
|
|
225
269
|
}
|
|
226
270
|
|
|
227
|
-
function composeBuild(report, { now, recordedBy, fingerprint }) {
|
|
271
|
+
function composeBuild(report, { now, recordedBy, fingerprint, buildEnvironment = null }) {
|
|
228
272
|
const sourcePackageFingerprint = currentSourcePackageMaterialFingerprint(report);
|
|
273
|
+
const previousAssembly = stageObject(report, "assembly");
|
|
229
274
|
const assembly = {
|
|
230
|
-
...withoutKeys(
|
|
275
|
+
...withoutKeys(previousAssembly, ["source_package_material_fingerprint"]),
|
|
276
|
+
...(buildEnvironment ? { evidence: { ...(isObject(previousAssembly.evidence) ? previousAssembly.evidence : {}), build_environment: buildEnvironment } } : {}),
|
|
231
277
|
stage: "assembly",
|
|
232
278
|
status: "completed",
|
|
233
279
|
build_fingerprint: fingerprint,
|
|
@@ -264,7 +310,7 @@ function composePolish(report, { now, recordedBy, fingerprint, input }) {
|
|
|
264
310
|
...input.evidence,
|
|
265
311
|
visual_review: {
|
|
266
312
|
...input.evidence.visual_review,
|
|
267
|
-
...(Object.hasOwn(previousVisual,
|
|
313
|
+
...Object.fromEntries(PACKAGE_OWNED_VISUAL_REVIEW_KEYS.filter((key) => Object.hasOwn(previousVisual, key)).map((key) => [key, previousVisual[key]])),
|
|
268
314
|
},
|
|
269
315
|
}
|
|
270
316
|
: previous.evidence;
|
|
@@ -294,6 +340,223 @@ function composePolish(report, { now, recordedBy, fingerprint, input }) {
|
|
|
294
340
|
return { report: nextReport, context: null };
|
|
295
341
|
}
|
|
296
342
|
|
|
343
|
+
// The stylesheet every family's commerce pages load first; the brand layer
|
|
344
|
+
// must come after it so its --brand--* values win.
|
|
345
|
+
const CORE_STYLESHEET = "next-core.css";
|
|
346
|
+
|
|
347
|
+
function stylesheetHrefs(html) {
|
|
348
|
+
return [...html.matchAll(/<link\b[^>]*>/gi)]
|
|
349
|
+
.map((match) => match[0])
|
|
350
|
+
.filter((tag) => /\brel\s*=\s*["']?[^"'>]*\bstylesheet\b/i.test(tag))
|
|
351
|
+
.map((tag) => (tag.match(/\bhref\s*=\s*["']([^"']+)["']/i) || [])[1])
|
|
352
|
+
.filter(Boolean);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const hrefName = (href) => href.split(/[?#]/)[0].split("/").pop();
|
|
356
|
+
const routeKey = (route) => String(route || "").replace(/^\/+|\/+$/g, "");
|
|
357
|
+
|
|
358
|
+
// What `record theme` stands on, read from each built commerce page's
|
|
359
|
+
// stylesheet links in document order. A page that loads next-core.css renders
|
|
360
|
+
// family components, so it must load a brand layer (brand-theme.css or
|
|
361
|
+
// checkout-brand.css) after it, and that file must be in the built output. A
|
|
362
|
+
// page that loads neither renders the design's own markup and needs no brand
|
|
363
|
+
// layer (docs/brand-theme-bridge.md, "Where next-core.css belongs"). Any page
|
|
364
|
+
// that breaks the rule is a refusal naming the page.
|
|
365
|
+
function brandLayerFacts(doctor) {
|
|
366
|
+
const derived = doctor.derived || {};
|
|
367
|
+
const commerce = commerceScopeFromScope(derived.scope);
|
|
368
|
+
if (!commerce.all.length) {
|
|
369
|
+
throw refuseRecord("theme", ["The campaign ships no commerce pages, so there is no brand layer to record; the theme gate does not apply."]);
|
|
370
|
+
}
|
|
371
|
+
const site = resolveBuiltSiteScope(derived.target_repo, { slug: derived.public_route_slug });
|
|
372
|
+
if (!site.ok) throw refuseRecord("theme", [site.error]);
|
|
373
|
+
const byRoute = new Map(site.pages.map((page) => [routeKey(page.route), page]));
|
|
374
|
+
const byId = new Map(site.pages.map((page) => [page.page_id, page]));
|
|
375
|
+
const problems = [];
|
|
376
|
+
const evidence = [];
|
|
377
|
+
const layers = [];
|
|
378
|
+
const styled = [];
|
|
379
|
+
const unstyled = [];
|
|
380
|
+
for (const page of commerce.built) {
|
|
381
|
+
const label = page.page_id || page.route || page.type;
|
|
382
|
+
const built = byRoute.get(routeKey(page.route)) || byId.get(page.page_id);
|
|
383
|
+
if (!built) {
|
|
384
|
+
problems.push(`${label}: no built page in ${relative(derived.target_repo, site.campaign_dir) || "."}; run the page-kit build, then ${cmd("record")} build.`);
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
const builtRel = relative(derived.target_repo, built.built_path);
|
|
388
|
+
const hrefs = stylesheetHrefs(readFileSync(built.built_path, "utf8"));
|
|
389
|
+
const core = hrefs.findIndex((href) => hrefName(href) === CORE_STYLESHEET);
|
|
390
|
+
const brand = hrefs.findIndex((href, index) => index > core && BRAND_LAYER_FILENAMES.has(hrefName(href)));
|
|
391
|
+
if (core < 0) {
|
|
392
|
+
if (hrefs.some((href) => BRAND_LAYER_FILENAMES.has(hrefName(href)))) {
|
|
393
|
+
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.`);
|
|
394
|
+
} else {
|
|
395
|
+
unstyled.push(label);
|
|
396
|
+
evidence.push(`${label}: ${builtRel} loads neither ${CORE_STYLESHEET} nor a brand layer (the design's own markup).`);
|
|
397
|
+
}
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
if (brand < 0) {
|
|
401
|
+
const early = hrefs.find((href) => BRAND_LAYER_FILENAMES.has(hrefName(href)));
|
|
402
|
+
problems.push(early
|
|
403
|
+
? `${label}: ${builtRel} links ${early} before ${CORE_STYLESHEET}; list it after ${CORE_STYLESHEET} in the page's frontmatter styles and rebuild.`
|
|
404
|
+
: `${label}: ${builtRel} links no brand layer (${[...BRAND_LAYER_FILENAMES].join(" or ")}) after ${CORE_STYLESHEET}.`);
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
const href = hrefs[brand].split(/[?#]/)[0];
|
|
408
|
+
if (/^(?:[a-z][a-z0-9+.-]*:)?\/\//i.test(href)) {
|
|
409
|
+
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.`);
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
const file = href.startsWith("/") ? join(site.site_root, href) : resolve(dirname(built.built_path), href);
|
|
413
|
+
if (!existsSync(file)) {
|
|
414
|
+
problems.push(`${label}: ${builtRel} links ${hrefs[brand]}, which is not in the built output.`);
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
layers.push(file);
|
|
418
|
+
styled.push(label);
|
|
419
|
+
evidence.push(`${label}: ${builtRel} loads ${relative(derived.target_repo, file)} after ${CORE_STYLESHEET}.`);
|
|
420
|
+
}
|
|
421
|
+
if (problems.length) throw refuseRecord("theme", problems);
|
|
422
|
+
if (!styled.length) {
|
|
423
|
+
const outOfScope = commerce.out_of_scope.map((page) => page.page_id || page.route || page.type);
|
|
424
|
+
throw refuseRecord("theme", [!commerce.built.length
|
|
425
|
+
? `No commerce page is built in this scope (declared but not built: ${outOfScope.join(", ")}); build them, run ${cmd("record")} build, then record theme.`
|
|
426
|
+
: `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.`]);
|
|
427
|
+
}
|
|
428
|
+
const cssPath = relative(derived.target_repo, layers[0]);
|
|
429
|
+
const generated = resolve(derived.target_repo, ".campaign-runtime/theme/brand-theme.css");
|
|
430
|
+
if (existsSync(generated)) {
|
|
431
|
+
const same = readFileSync(generated, "utf8") === readFileSync(layers[0], "utf8");
|
|
432
|
+
evidence.push(`${cssPath} ${same ? "matches" : "differs from"} the generated .campaign-runtime/theme/brand-theme.css.`);
|
|
433
|
+
}
|
|
434
|
+
return {
|
|
435
|
+
cssPath,
|
|
436
|
+
commercePages: styled,
|
|
437
|
+
outOfScope: commerce.out_of_scope.map((page) => page.page_id || page.route || page.type),
|
|
438
|
+
evidence,
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// An applied brand layer replaces any earlier waiver: the gate reads a waiver
|
|
443
|
+
// first, and the two answer the same question opposite ways. Other fields
|
|
444
|
+
// stay: `warnings` come from theme inspect, and `repair_loop_defect` is what
|
|
445
|
+
// `record polish` recorded about the repair loop, history the gate never reads.
|
|
446
|
+
function composeTheme(report, { now, recordedBy, layer }) {
|
|
447
|
+
const theme = {
|
|
448
|
+
...(isObject(report.theme) ? report.theme : {}),
|
|
449
|
+
status: "applied",
|
|
450
|
+
css_path: layer.cssPath,
|
|
451
|
+
load_order: "after-next-core",
|
|
452
|
+
commerce_pages: layer.commercePages,
|
|
453
|
+
evidence: layer.evidence,
|
|
454
|
+
waiver: null,
|
|
455
|
+
recorded_by: recordedBy,
|
|
456
|
+
recorded_at: now,
|
|
457
|
+
};
|
|
458
|
+
return { report: { ...report, theme }, context: null };
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// The local preview a `record deploy` URL must name: a loopback http(s)
|
|
462
|
+
// origin serving the packet's route root ("/<slug>/", or "/" for a
|
|
463
|
+
// root-served campaign) of a local-serve packet. Returns the URL as recorded
|
|
464
|
+
// (origin plus route root) and the problems that refuse it.
|
|
465
|
+
function localPreviewUrl(packet, rawUrl) {
|
|
466
|
+
if (!isLocalServePacket(packet)) {
|
|
467
|
+
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.`] };
|
|
468
|
+
}
|
|
469
|
+
let url;
|
|
470
|
+
try {
|
|
471
|
+
url = new URL(String(rawUrl));
|
|
472
|
+
} catch {
|
|
473
|
+
return { url: null, problems: [`--base-url ${JSON.stringify(rawUrl)} is not a URL; give the served address, for example http://localhost:<port>/<slug>/.`] };
|
|
474
|
+
}
|
|
475
|
+
const problems = [];
|
|
476
|
+
if (!/^https?:$/.test(url.protocol)) problems.push(`--base-url must be http or https (got ${url.protocol}).`);
|
|
477
|
+
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].`);
|
|
478
|
+
const routeRoot = campaignRouteRoot(packet);
|
|
479
|
+
if (!routeRoot) {
|
|
480
|
+
problems.push("The packet records no campaign.public_route_slug, so the served route root is unknown; record it first.");
|
|
481
|
+
} else if (`/${routeKey(url.pathname.replace(/\/index\.html?$/i, "/"))}/`.replace("//", "/") !== routeRoot) {
|
|
482
|
+
problems.push(`--base-url ${url.href} serves ${url.pathname}, but this campaign's route root is ${routeRoot}; give ${url.origin}${routeRoot}.`);
|
|
483
|
+
}
|
|
484
|
+
return { url: problems.length ? null : `${url.origin}${routeRoot}`, problems };
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const PROBE_TIMEOUT_MS = 5_000;
|
|
488
|
+
const PROBE_MAX_REDIRECTS = 3;
|
|
489
|
+
|
|
490
|
+
// One page request. Redirects are followed only within the preview's own
|
|
491
|
+
// origin, so a probe never leaves the machine; a redirect elsewhere is
|
|
492
|
+
// reported, not followed.
|
|
493
|
+
async function probePage(target, fetchImpl) {
|
|
494
|
+
let current = target;
|
|
495
|
+
try {
|
|
496
|
+
for (let hop = 0; hop <= PROBE_MAX_REDIRECTS; hop += 1) {
|
|
497
|
+
const response = await fetchImpl(current, { redirect: "manual", signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
|
|
498
|
+
await response.body?.cancel();
|
|
499
|
+
const location = response.headers?.get?.("location");
|
|
500
|
+
if (!(response.status >= 300 && response.status < 400) || !location) return { url: target, status: response.status };
|
|
501
|
+
const next = new URL(location, current);
|
|
502
|
+
if (next.origin !== new URL(target).origin) return { url: target, status: null, error: `redirects to ${next.href}, off this preview; nothing was requested there` };
|
|
503
|
+
current = next.href;
|
|
504
|
+
}
|
|
505
|
+
return { url: target, status: null, error: `more than ${PROBE_MAX_REDIRECTS} redirects` };
|
|
506
|
+
} catch (error) {
|
|
507
|
+
const reason = error?.name === "TimeoutError" ? `no answer within ${PROBE_TIMEOUT_MS / 1000} s` : String(error?.cause?.code || error?.message || error);
|
|
508
|
+
// A local static server usually speaks plain http; say so when https fails.
|
|
509
|
+
return { url: target, status: null, error: new URL(target).protocol === "https:" ? `${reason} (over https; a local static server usually serves http)` : reason };
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* What only the running server can say, read before the target lock (like
|
|
515
|
+
* the polish --evidence file): the URL names this campaign's local preview,
|
|
516
|
+
* and every built page under it answers 2xx. The lock re-checks the
|
|
517
|
+
* packet and the built output; the probe is never trusted for either.
|
|
518
|
+
*/
|
|
519
|
+
export async function probeLocalPreview({ packetPath, baseUrl, fetchImpl = globalThis.fetch }) {
|
|
520
|
+
const packet = readPacketFile("deploy", packetPath);
|
|
521
|
+
const { url, problems } = localPreviewUrl(packet, baseUrl);
|
|
522
|
+
if (problems.length) throw refuseRecord("deploy", problems);
|
|
523
|
+
const site = resolveBuiltSiteScope(targetRepoFor(packetPath, packet), { slug: packet.campaign.public_route_slug });
|
|
524
|
+
if (!site.ok) throw refuseRecord("deploy", [site.error]);
|
|
525
|
+
// The built pages, not the bare route root: a funnel often has no index
|
|
526
|
+
// page there, and servers answer that differently (404, a listing).
|
|
527
|
+
const targets = [...new Set(site.pages.map((page) => (routeKey(page.route) ? new URL(`${routeKey(page.route)}/`, url).href : url)))];
|
|
528
|
+
// Requested together; the evidence keeps the built pages' order.
|
|
529
|
+
const routes = await Promise.all(targets.map((target) => probePage(target, fetchImpl)));
|
|
530
|
+
const failed = routes.filter((route) => !(route.status >= 200 && route.status < 300));
|
|
531
|
+
if (failed.length) {
|
|
532
|
+
throw refuseRecord("deploy", [
|
|
533
|
+
...failed.map((route) => `${route.url}: ${route.status ? `HTTP ${route.status}` : route.error}.`),
|
|
534
|
+
`Serve the current _site/ build so every page answers at ${url}, then run ${cmd("record")} deploy again.`,
|
|
535
|
+
]);
|
|
536
|
+
}
|
|
537
|
+
return { url, routes };
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// A recorded local preview: the packet's deploy.preview_url, and stages.deploy
|
|
541
|
+
// completed with the URL in outputs (what next reads) and the probe as evidence.
|
|
542
|
+
function composeDeploy(report, packet, { now, recordedBy, probe }) {
|
|
543
|
+
const deploy = {
|
|
544
|
+
...stageObject(report, "deploy"),
|
|
545
|
+
stage: "deploy",
|
|
546
|
+
status: "completed",
|
|
547
|
+
outputs: [probe.url],
|
|
548
|
+
evidence: probe.routes.map((route) => `${route.url} answered HTTP ${route.status}`),
|
|
549
|
+
completed_at: now,
|
|
550
|
+
recorded_by: recordedBy,
|
|
551
|
+
blockers: [],
|
|
552
|
+
};
|
|
553
|
+
return {
|
|
554
|
+
report: { ...report, stages: { ...report.stages, deploy } },
|
|
555
|
+
context: null,
|
|
556
|
+
packet: { ...packet, deploy: { ...packet.deploy, preview_url: probe.url } },
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
|
|
297
560
|
// Every check a written record must pass, over exactly what would be written.
|
|
298
561
|
function validateRecord(stage, { report, context, packet, fingerprint }) {
|
|
299
562
|
const problems = [
|
|
@@ -352,6 +615,9 @@ function ladderProblems(stage, doctor, report) {
|
|
|
352
615
|
if (gate) return [`next answers prepare-build: ${gate.reason}`];
|
|
353
616
|
const problems = [];
|
|
354
617
|
for (const earlier of NEXT_STAGE_ORDER.slice(0, NEXT_STAGE_ORDER.indexOf(stage))) {
|
|
618
|
+
// The rule next's stage picker reads: on the local preview a missing polish
|
|
619
|
+
// is carried forward (local-preview-policy), and next moves on to deploy.
|
|
620
|
+
if (earlier === "polish" && doctor.derived?.polish_gate?.status === CARRIED_FORWARD) continue;
|
|
355
621
|
const key = reportKeyForCliStage(earlier);
|
|
356
622
|
const status = String(report.stages[key]?.status || "");
|
|
357
623
|
if (!stageIsTerminal(status)) {
|
|
@@ -369,7 +635,8 @@ function doctorFacts(stage, doctor, report, packet) {
|
|
|
369
635
|
const derived = doctor.derived || {};
|
|
370
636
|
const binding = bindingProblems(doctor, report, packet);
|
|
371
637
|
if (binding.length) throw refuseRecord(stage, binding);
|
|
372
|
-
|
|
638
|
+
// The brand layer is applied to built output, so theme is checked as polish is.
|
|
639
|
+
const ladder = ladderProblems(stage === "theme" ? "polish" : stage, doctor, report);
|
|
373
640
|
if (ladder.length) throw refuseRecord(stage, ladder);
|
|
374
641
|
if (stage === "setup") {
|
|
375
642
|
const outputDir = optionalString(derived.target_output_dir);
|
|
@@ -386,13 +653,15 @@ function doctorFacts(stage, doctor, report, packet) {
|
|
|
386
653
|
if (stage === "build" && derived.scaffold_required === true) {
|
|
387
654
|
throw refuseRecord(stage, [`Setup is still required (${derived.scaffold_reason || "Build Context scaffold.required is true"}); run ${cmd("record")} setup first.`]);
|
|
388
655
|
}
|
|
389
|
-
if (stage === "polish") {
|
|
656
|
+
if (stage === "polish" || stage === "theme" || stage === "deploy") {
|
|
390
657
|
const recorded = optionalString(report?.stages?.assembly?.build_fingerprint);
|
|
391
658
|
if (!String(report?.stages?.assembly?.status || "").startsWith("completed") || !recorded) {
|
|
392
659
|
throw refuseRecord(stage, [`Build is not recorded (stages.assembly needs a completed status and build_fingerprint); run ${cmd("record")} build first.`]);
|
|
393
660
|
}
|
|
394
661
|
if (recorded !== fingerprint) {
|
|
395
|
-
throw refuseRecord(stage, [
|
|
662
|
+
throw refuseRecord(stage, [stage === "theme" || stage === "deploy"
|
|
663
|
+
? `The built output changed since build was recorded (recorded ${recorded}, current ${fingerprint}); run ${cmd("record")} build, then record ${stage} again.`
|
|
664
|
+
: `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
665
|
}
|
|
397
666
|
}
|
|
398
667
|
return {
|
|
@@ -416,6 +685,37 @@ function assertOutputUnchanged(stage, facts) {
|
|
|
416
685
|
}
|
|
417
686
|
}
|
|
418
687
|
|
|
688
|
+
// Under the lock, the probe is checked against the packet as it is now (it
|
|
689
|
+
// could have been retargeted while the probe ran), and the theme gate, which
|
|
690
|
+
// blocks deploy, must not be blocked.
|
|
691
|
+
function deployFacts(doctor, packet, probe) {
|
|
692
|
+
const { url, problems } = localPreviewUrl(packet, probe.url);
|
|
693
|
+
if (problems.length) throw refuseRecord("deploy", problems);
|
|
694
|
+
if (url !== probe.url) {
|
|
695
|
+
throw refuseRecord("deploy", [`The packet's route root changed while the preview was probed (probed ${probe.url}, now ${url}); run ${cmd("record")} deploy again.`]);
|
|
696
|
+
}
|
|
697
|
+
const gate = doctor.derived?.theme_gate;
|
|
698
|
+
if (gate?.status === "blocked") {
|
|
699
|
+
throw refuseRecord("deploy", [
|
|
700
|
+
`${gate.code}: ${gate.reason}`,
|
|
701
|
+
...(gate.required_actions || []).map((action) => `required action: ${action.command || action.description}`),
|
|
702
|
+
]);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* The `record` command as the CLI runs it: `record deploy` first probes the
|
|
708
|
+
* served preview (asynchronously, before the target lock), then every kind
|
|
709
|
+
* records through recordStageCommand.
|
|
710
|
+
*/
|
|
711
|
+
export async function recordCommand(args, options = {}) {
|
|
712
|
+
const { stage, packetPath, baseUrl } = parseRecordArgs(args);
|
|
713
|
+
const probe = stage === "deploy" && existsSync(packetPath)
|
|
714
|
+
? await probeLocalPreview({ packetPath, baseUrl, ...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}) })
|
|
715
|
+
: null;
|
|
716
|
+
return recordStageCommand(args, { ...options, probe });
|
|
717
|
+
}
|
|
718
|
+
|
|
419
719
|
function readPacketFile(stage, packetPath) {
|
|
420
720
|
try {
|
|
421
721
|
return JSON.parse(readFileSync(packetPath, "utf8"));
|
|
@@ -434,11 +734,13 @@ function readPacketFile(stage, packetPath) {
|
|
|
434
734
|
* target lock, after doctor has read the target and before anything is
|
|
435
735
|
* composed or written.
|
|
436
736
|
*/
|
|
437
|
-
export function recordStageCommand(args, { now = () => new Date(), beforeLock = null, afterDoctorRead = null } = {}) {
|
|
438
|
-
const { stage, packetPath, evidencePath, dryRun } = parseRecordArgs(args);
|
|
737
|
+
export function recordStageCommand(args, { now = () => new Date(), beforeLock = null, afterDoctorRead = null, probe = null } = {}) {
|
|
738
|
+
const { stage, packetPath, evidencePath, dryRun, buildEnvironment } = parseRecordArgs(args);
|
|
439
739
|
if (!existsSync(packetPath)) throw new Error(`record ${stage}: Build Packet not found at ${packetPath}; run ${cmd("start")} or ${cmd("prepare-build")} first.`);
|
|
740
|
+
if (stage === "deploy" && !probe) throw new Error("record deploy needs the served-route probe; run it through recordCommand.");
|
|
440
741
|
// Operator input, not target state: no campaigns-os writer produces it.
|
|
441
|
-
|
|
742
|
+
// For deploy, the probe of the running server (probeLocalPreview).
|
|
743
|
+
const input = stage === "polish" ? readPolishEvidenceFile(evidencePath) : stage === "deploy" ? probe : null;
|
|
442
744
|
const sidecars = {
|
|
443
745
|
contextPath: args.context ? resolve(args.context) : undefined,
|
|
444
746
|
reportPath: args.report ? resolve(args.report) : undefined,
|
|
@@ -454,16 +756,24 @@ export function recordStageCommand(args, { now = () => new Date(), beforeLock =
|
|
|
454
756
|
// A dry run writes nothing, so it takes no lock and creates no lock files
|
|
455
757
|
// (the commitAssemblyReport preview convention); it reads in the same order.
|
|
456
758
|
const run = () => recordUnderLock({
|
|
457
|
-
stage, packetPath, sidecars, lockedTarget, input, dryRun, timestamp, recordedBy, afterDoctorRead,
|
|
759
|
+
stage, packetPath, sidecars, lockedTarget, input, dryRun, timestamp, recordedBy, afterDoctorRead, buildEnvironment,
|
|
458
760
|
});
|
|
459
761
|
const recorded = dryRun ? run() : withTargetLockSync(lockedTarget, run, { command: `record ${stage}` });
|
|
460
|
-
const { composed, facts, reportPath, contextPath, after } = recorded;
|
|
762
|
+
const { composed, facts, layer, reportPath, contextPath, after } = recorded;
|
|
461
763
|
|
|
462
764
|
const stageKey = stage === "build" ? "assembly" : stage;
|
|
463
|
-
const writes = [...(composed.context ? [contextPath] : []), reportPath];
|
|
464
|
-
const ready = [
|
|
765
|
+
const writes = [...(composed.context ? [contextPath] : []), ...(composed.packet ? [packetPath] : []), reportPath];
|
|
766
|
+
const ready = stage === "deploy" ? [
|
|
767
|
+
`stages.deploy.status = completed; deploy.preview_url = ${input.url}`,
|
|
768
|
+
...composed.report.stages.deploy.evidence,
|
|
769
|
+
] : stage === "theme" ? [
|
|
770
|
+
`theme.status = applied, load_order = after-next-core (${layer.cssPath})`,
|
|
771
|
+
...layer.evidence,
|
|
772
|
+
...(layer.outOfScope.length ? [`Not built in this scope, so not checked: ${layer.outOfScope.join(", ")}; run record theme again after building them.`] : []),
|
|
773
|
+
] : [
|
|
465
774
|
`stages.${stageKey}.status = ${composed.report.stages[stageKey].status}`,
|
|
466
775
|
...(facts.fingerprint ? [`build output fingerprint ${facts.fingerprint} (doctor derived.build_output_fingerprint.value)`] : []),
|
|
776
|
+
...(stage === "build" && buildEnvironment ? [`stages.assembly.evidence.build_environment = ${buildEnvironment}`] : []),
|
|
467
777
|
...(stage === "build" ? [`stages.polish.status = ${composed.report.stages.polish.status}`] : []),
|
|
468
778
|
...(composed.context ? ["Build Context scaffold.required = false"] : []),
|
|
469
779
|
];
|
|
@@ -477,7 +787,7 @@ export function recordStageCommand(args, { now = () => new Date(), beforeLock =
|
|
|
477
787
|
...(composed.context ? { context_path: contextPath } : {}),
|
|
478
788
|
...(dryRun ? { would_write: writes } : { written: writes }),
|
|
479
789
|
build_fingerprint: facts.fingerprint || null,
|
|
480
|
-
record: composed.report.stages[stageKey],
|
|
790
|
+
record: stage === "theme" ? composed.report.theme : composed.report.stages[stageKey],
|
|
481
791
|
...(stage === "build" ? { polish: composed.report.stages.polish } : {}),
|
|
482
792
|
...(composed.context ? { scaffold: composed.context.scaffold } : {}),
|
|
483
793
|
...(stage === "polish" && input.hasRepairLoopDefect ? { repair_loop_defect: input.repairLoopDefect } : {}),
|
|
@@ -496,7 +806,7 @@ export function recordStageCommand(args, { now = () => new Date(), beforeLock =
|
|
|
496
806
|
// re-check, and the post-write doctor read for `next_stage`. No campaigns-os
|
|
497
807
|
// writer can rebind, rewrite or republish any of them between the read and
|
|
498
808
|
// the write.
|
|
499
|
-
function recordUnderLock({ stage, packetPath, sidecars, lockedTarget, input, dryRun, timestamp, recordedBy, afterDoctorRead }) {
|
|
809
|
+
function recordUnderLock({ stage, packetPath, sidecars, lockedTarget, input, dryRun, timestamp, recordedBy, afterDoctorRead, buildEnvironment = null }) {
|
|
500
810
|
// The same workspace `next` resolves, so the record lands in the report
|
|
501
811
|
// `next` reads now, not the one it read before the lock was free.
|
|
502
812
|
const workspace = resolveCampaignWorkspace(packetPath, { ...sidecars, followContextPointer: true });
|
|
@@ -508,6 +818,7 @@ function recordUnderLock({ stage, packetPath, sidecars, lockedTarget, input, dry
|
|
|
508
818
|
|
|
509
819
|
let composed = null;
|
|
510
820
|
let facts = null;
|
|
821
|
+
let layer = null;
|
|
511
822
|
const compose = (report) => {
|
|
512
823
|
if (!isObject(report) || !isObject(report.stages)) throw refuseRecord(stage, [`Assembly Report at ${reportPath} has no stages object.`]);
|
|
513
824
|
const context = stage === "setup" ? readJsonIfExists(contextPath) : null;
|
|
@@ -525,27 +836,36 @@ function recordUnderLock({ stage, packetPath, sidecars, lockedTarget, input, dry
|
|
|
525
836
|
}
|
|
526
837
|
facts = doctorFacts(stage, doctor, report, packet);
|
|
527
838
|
if (typeof afterDoctorRead === "function") afterDoctorRead();
|
|
839
|
+
if (stage === "theme") layer = brandLayerFacts(doctor);
|
|
840
|
+
if (stage === "deploy") deployFacts(doctor, packet, input);
|
|
528
841
|
const next = stage === "setup"
|
|
529
842
|
? composeSetup(report, context, { now: timestamp, recordedBy })
|
|
530
843
|
: stage === "build"
|
|
531
|
-
? composeBuild(report, { now: timestamp, recordedBy, fingerprint: facts.fingerprint })
|
|
532
|
-
:
|
|
844
|
+
? composeBuild(report, { now: timestamp, recordedBy, fingerprint: facts.fingerprint, buildEnvironment })
|
|
845
|
+
: stage === "theme"
|
|
846
|
+
? composeTheme(report, { now: timestamp, recordedBy, layer })
|
|
847
|
+
: stage === "deploy"
|
|
848
|
+
? composeDeploy(report, packet, { now: timestamp, recordedBy, probe: input })
|
|
849
|
+
: composePolish(report, { now: timestamp, recordedBy, fingerprint: facts.fingerprint, input });
|
|
533
850
|
applyDerivedAssemblyReportSummary(next.report);
|
|
851
|
+
// The packet's one new value, deploy.preview_url, is checked by
|
|
852
|
+
// localPreviewUrl; the rest of the packet is as the operator left it.
|
|
534
853
|
validateRecord(stage, { report: next.report, context: next.context, packet, fingerprint: facts.fingerprint });
|
|
535
854
|
assertOutputUnchanged(stage, facts);
|
|
536
855
|
composed = next;
|
|
537
856
|
if (dryRun) return null;
|
|
538
857
|
// Written inside the report's critical section, after every check and
|
|
539
|
-
// before the report itself, so the
|
|
858
|
+
// before the report itself, so the files move together.
|
|
540
859
|
if (next.context) writeJsonAtomic(contextPath, next.context);
|
|
860
|
+
if (next.packet) writeJsonAtomic(packetPath, next.packet);
|
|
541
861
|
return next.report;
|
|
542
862
|
};
|
|
543
863
|
// Already inside the target lock, which commitAssemblyReport re-enters.
|
|
544
864
|
commitAssemblyReport(workspace, compose, {
|
|
545
865
|
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.`,
|
|
866
|
+
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
867
|
...(dryRun ? { lock: false } : {}),
|
|
548
868
|
});
|
|
549
869
|
const after = dryRun ? null : doctorPacket(packetPath, sidecars);
|
|
550
|
-
return { composed, facts, reportPath, contextPath, after };
|
|
870
|
+
return { composed, facts, layer, reportPath, contextPath, after };
|
|
551
871
|
}
|
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",
|