@nextcommerce/campaigns-os 1.57.0 → 1.60.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 +176 -0
- package/README.md +6 -5
- package/campaign-spec/dist/checkout-flow.d.ts +7 -0
- package/campaign-spec/dist/checkout-flow.js +17 -1
- package/compatibility.json +1 -1
- package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
- package/contracts/commerce-surface-catalog.json +27 -28
- package/contracts/effects.v1.json +5 -5
- package/contracts/release-ledger.json +975 -0
- package/contracts/supported-surface.json +4 -4
- package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
- package/docs/build-packet.md +35 -19
- package/docs/campaign-build-brief.md +1 -1
- package/docs/campaigns-os-build-flow.md +1 -1
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/polish-evidence.md +1 -1
- package/docs/qa-and-test-orders.md +54 -12
- package/docs/runtime-readiness.md +1 -1
- package/docs/skills-revision.md +10 -10
- package/package.json +1 -1
- package/schemas/campaign-runtime-build-context.v0.schema.json +2 -1
- package/schemas/campaign-runtime-build-packet.v0.schema.json +2 -1
- 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 +7 -7
- 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 +9 -10
- package/skills/next-campaigns-qa/SKILL.md +3 -3
- package/skills/next-campaigns-sdk-bump/SKILL.md +193 -0
- package/skills.json +18 -10
- package/src/built-site-scope.mjs +96 -12
- package/src/campaign-identity.mjs +6 -4
- package/src/cli.mjs +50 -81
- package/src/doctor/checks.mjs +79 -6
- package/src/doctor/inspect.mjs +2 -8
- package/src/doctor/next-step.mjs +9 -17
- package/src/orchestration-stage-contract.mjs +6 -3
- package/src/page-kit-build-summary.mjs +21 -1
- package/src/page-kit-campaign-config.mjs +19 -0
- package/src/polish-gate.mjs +1 -5
- package/src/polish-node.mjs +5 -4
- package/src/progress-node.mjs +2 -1
- package/src/qa-browser.mjs +137 -2
- package/src/qa-cart-entry.mjs +105 -0
- package/src/qa-node.mjs +3 -1
- package/src/qa-verdict.mjs +24 -1
- package/src/sdk-attribute-index.mjs +8 -7
- package/src/source-html-intake.mjs +27 -18
- package/src/spec-derive.mjs +24 -9
- package/src/stage-ledger.mjs +27 -1
- package/src/stage-record.mjs +218 -35
package/src/spec-derive.mjs
CHANGED
|
@@ -56,15 +56,29 @@ const ROUTING_HINT_FIELDS = Object.freeze({
|
|
|
56
56
|
});
|
|
57
57
|
|
|
58
58
|
// The directories page-kit's own discovery ignores (`_layouts`, `_includes`;
|
|
59
|
-
// everything else under the campaign root, `assets/` and `_data/`
|
|
60
|
-
// is rendered when it is .html), plus the two no build reads. The
|
|
61
|
-
// prunes with this and pageRouteForFile filters with it, so the
|
|
62
|
-
// in one place.
|
|
59
|
+
// on 0.2.x everything else under the campaign root, `assets/` and `_data/`
|
|
60
|
+
// included, is rendered when it is .html), plus the two no build reads. The
|
|
61
|
+
// CLI walker prunes with this and pageRouteForFile filters with it, so the
|
|
62
|
+
// list lives in one place.
|
|
63
63
|
export const PAGE_TREE_IGNORED_DIRS = Object.freeze(["_layouts", "_includes", "node_modules", ".git"]);
|
|
64
64
|
export function isPageTreeIgnoredDir(name) {
|
|
65
65
|
return PAGE_TREE_IGNORED_DIRS.includes(name);
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
// Whether a campaign-relative file sits where page-kit renders no page. From
|
|
69
|
+
// 0.3.0 page-kit also skips the campaign's top-level `assets/` (its
|
|
70
|
+
// `*/assets/**` glob; a nested `offers/assets/` still renders), so the caller
|
|
71
|
+
// says which rule the target's installed page-kit follows. Backslashes and
|
|
72
|
+
// leading slashes read as pageRouteForFile reads them.
|
|
73
|
+
export function isPageTreeIgnoredPath(relativePath, { assetsHtmlIgnored = false } = {}) {
|
|
74
|
+
const dirs = normalizePageTreePath(relativePath).split("/").slice(0, -1);
|
|
75
|
+
return dirs.some(isPageTreeIgnoredDir) || (assetsHtmlIgnored && dirs[0] === "assets");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function normalizePageTreePath(relativePath) {
|
|
79
|
+
return String(relativePath || "").replace(/\\/g, "/").replace(/^\/+/, "");
|
|
80
|
+
}
|
|
81
|
+
|
|
68
82
|
function isPlainObject(value) {
|
|
69
83
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
70
84
|
}
|
|
@@ -114,13 +128,14 @@ function containerProblem(spec, path) {
|
|
|
114
128
|
// `upsell/`, intermediate directories ignored with its NESTED_NO_PERMALINK
|
|
115
129
|
// warning), and `index.html` is the entry route "". A nested `index.html`
|
|
116
130
|
// would collide with the campaign root (page-kit's DUPLICATE_OUTPUT), so it
|
|
117
|
-
// is not a page this reads. Files under `_layouts/` or `_includes/`
|
|
118
|
-
// null, as page-kit
|
|
119
|
-
|
|
120
|
-
|
|
131
|
+
// is not a page this reads. Files under `_layouts/` or `_includes/` (and,
|
|
132
|
+
// with assetsHtmlIgnored, the top-level `assets/`) return null, as page-kit
|
|
133
|
+
// never renders them.
|
|
134
|
+
export function pageRouteForFile(relativePath, { assetsHtmlIgnored = false } = {}) {
|
|
135
|
+
const path = normalizePageTreePath(relativePath);
|
|
121
136
|
if (!path || !/\.html$/i.test(path)) return null;
|
|
122
137
|
const segments = path.split("/");
|
|
123
|
-
if (
|
|
138
|
+
if (isPageTreeIgnoredPath(path, { assetsHtmlIgnored })) return null;
|
|
124
139
|
const basename = segments[segments.length - 1].replace(/\.html$/i, "");
|
|
125
140
|
if (basename === "index") return segments.length === 1 ? "" : null;
|
|
126
141
|
return `${basename}/`;
|
package/src/stage-ledger.mjs
CHANGED
|
@@ -11,7 +11,6 @@ import {
|
|
|
11
11
|
NEXT_STAGE_CONTRACTS,
|
|
12
12
|
NEXT_STAGE_OWNERS,
|
|
13
13
|
stageIsBlocked,
|
|
14
|
-
stageIsTerminal,
|
|
15
14
|
} from "./orchestration-stage-contract.mjs";
|
|
16
15
|
|
|
17
16
|
const PRODUCER_STAGES = new Set(["doctor", "qa"]);
|
|
@@ -200,6 +199,33 @@ export function archiveSupersededRecord(stage, previous, { by, reason, at }) {
|
|
|
200
199
|
return appendHistoryEntry(isPlainObject(stage) ? { ...stage } : {}, historyEntry(previous, { by, reason, at }));
|
|
201
200
|
}
|
|
202
201
|
|
|
202
|
+
/**
|
|
203
|
+
* #668: the Polish record a CampaignSpec change moved to `polish.history`,
|
|
204
|
+
* restored as the live record stamped with `stamps` (the current inputs), or
|
|
205
|
+
* null. It is the same review of the same pages, so it is restored only when
|
|
206
|
+
* the live record's input_change is a CampaignSpec change over exactly
|
|
207
|
+
* `fingerprint`, the newest history entry is the completed Polish that change
|
|
208
|
+
* archived (same build fingerprint and source package fingerprint), and the
|
|
209
|
+
* brief's presentation stamp is unchanged: a presentation change still owes a
|
|
210
|
+
* new review. The caller decides that the operator kept this output.
|
|
211
|
+
*/
|
|
212
|
+
export function restorePolishKeptWithOutput(polish, { fingerprint, sourcePackageFingerprint = null, stamps = {}, completedStatuses }) {
|
|
213
|
+
const change = isPlainObject(polish) ? polish.input_change : null;
|
|
214
|
+
if (!isPlainObject(change) || change.reason !== "spec_material_changed" || change.superseded_build_fingerprint !== fingerprint) return null;
|
|
215
|
+
const history = Array.isArray(polish.history) ? polish.history : [];
|
|
216
|
+
const superseded = history[history.length - 1];
|
|
217
|
+
if (!isPlainObject(superseded) || superseded.archived_at !== change.at || !completedStatuses.includes(superseded.status)) return null;
|
|
218
|
+
if (superseded.source_build_fingerprint !== fingerprint) return null;
|
|
219
|
+
if ((superseded.source_package_material_fingerprint ?? null) !== (sourcePackageFingerprint ?? null)) return null;
|
|
220
|
+
if ((superseded.source_brief_material?.presentation ?? null) !== (stamps.source_brief_material?.presentation ?? null)) return null;
|
|
221
|
+
const restored = { ...polish };
|
|
222
|
+
for (const field of ["required_by", "required_for", "input_change", "source_brief_material", "source_spec_material_hash"]) delete restored[field];
|
|
223
|
+
for (const field of ["status", "completed_at", "recorded_by", "performed_by", "source_build_fingerprint", "source_package_material_fingerprint", "outputs", "evidence"]) {
|
|
224
|
+
if (Object.hasOwn(superseded, field)) restored[field] = JSON.parse(JSON.stringify(superseded[field]));
|
|
225
|
+
}
|
|
226
|
+
return { ...restored, stage: "polish", ...stamps };
|
|
227
|
+
}
|
|
228
|
+
|
|
203
229
|
/**
|
|
204
230
|
* True when any stage of `report` carries a non-empty history: recorded
|
|
205
231
|
* operator-visible state an intake must not silently drop.
|
package/src/stage-record.mjs
CHANGED
|
@@ -17,17 +17,20 @@
|
|
|
17
17
|
// polish` validate the context they read but write only the report. `record
|
|
18
18
|
// theme` writes `report.theme` only after reading, in each built commerce
|
|
19
19
|
// page, that a brand layer stylesheet is linked after next-core.css. `record
|
|
20
|
-
// deploy` records a
|
|
21
|
-
// stages.deploy, after every built page answers on the served loopback
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
// deploy` records a served deploy: the packet's deploy.preview_url and
|
|
21
|
+
// stages.deploy, after every built page answers on the served URL (loopback
|
|
22
|
+
// for a local-serve packet; https for a hosted one, whose served scripts and
|
|
23
|
+
// stylesheets must also match the built output byte for byte).
|
|
24
|
+
import { createHash } from "node:crypto";
|
|
25
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
26
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
24
27
|
import { fileURLToPath } from "node:url";
|
|
25
28
|
|
|
26
29
|
import Ajv2020 from "ajv/dist/2020.js";
|
|
27
30
|
|
|
28
31
|
import { BRAND_LAYER_FILENAMES } from "./brand-theme.mjs";
|
|
29
32
|
import { ADAPTER_DECISION_SCALAR_VALUES } from "./adapter-decision-contract.mjs";
|
|
30
|
-
import { computeBuildFingerprint, resolveBuiltSiteScope } from "./built-site-scope.mjs";
|
|
33
|
+
import { buildManifestEntries, computeBuildFingerprint, resolveBuiltSiteScope } from "./built-site-scope.mjs";
|
|
31
34
|
import { resolveCampaignWorkspace, targetRepoFor } from "./campaign-workspace.mjs";
|
|
32
35
|
import { isObject, optionalString, portableArtifactPaths, readJsonIfExists, requireArg } from "./cli-helpers.mjs";
|
|
33
36
|
import { LOCAL_PROOF_BUILD_ENVIRONMENT, LOCAL_PROOF_PRODUCTION_ENVIRONMENT, isLocalServePacket } from "./local-proof.mjs";
|
|
@@ -49,7 +52,7 @@ import {
|
|
|
49
52
|
import { evaluateRecordedHiddenEagerMediaCheckpoint } from "./polish-node.mjs";
|
|
50
53
|
import { effectiveStageStatus, effectiveStatusIsTerminal, inputStamps, stageWriteInputs } from "./input-currency.mjs";
|
|
51
54
|
import { demoteStages, recordBrief, recordSpec } from "./input-refresh.mjs";
|
|
52
|
-
import { applyDerivedAssemblyReportSummary, archiveStageRecord, archiveSupersededRecord, assemblyReportMatchesPacket, commitAssemblyReport, inputChangeFor } from "./stage-ledger.mjs";
|
|
55
|
+
import { applyDerivedAssemblyReportSummary, archiveStageRecord, archiveSupersededRecord, assemblyReportMatchesPacket, commitAssemblyReport, inputChangeFor, restorePolishKeptWithOutput } from "./stage-ledger.mjs";
|
|
53
56
|
import { withTargetLockSync } from "./target-lock.mjs";
|
|
54
57
|
import { commerceScopeFromScope } from "./theme-gate.mjs";
|
|
55
58
|
|
|
@@ -327,7 +330,7 @@ function currentStamps(inputs) {
|
|
|
327
330
|
return Object.fromEntries(Object.entries(stamps).filter(([, value]) => value !== null));
|
|
328
331
|
}
|
|
329
332
|
|
|
330
|
-
function composeBuild(report, { now, recordedBy, fingerprint, buildEnvironment = null, adapterDecisions = null, fallbackAdapterDecisions = null, inputs = {}, deviationReason = null }) {
|
|
333
|
+
function composeBuild(report, { now, recordedBy, fingerprint, manifest = null, buildEnvironment = null, adapterDecisions = null, fallbackAdapterDecisions = null, inputs = {}, deviationReason = null }) {
|
|
331
334
|
const nextReport = adapterDecisions ? { ...report, adapter_decisions: { ...(report.adapter_decisions || fallbackAdapterDecisions || {}), ...adapterDecisions } } : report;
|
|
332
335
|
const sourcePackageFingerprint = currentSourcePackageMaterialFingerprint(report);
|
|
333
336
|
const previousAssembly = stageObject(report, "assembly");
|
|
@@ -337,11 +340,15 @@ function composeBuild(report, { now, recordedBy, fingerprint, buildEnvironment =
|
|
|
337
340
|
const writeInputs = stageWriteInputs("assembly", inputs);
|
|
338
341
|
const detected = REPLACED_COMPLETED_STATUSES.includes(previousAssembly.status) ? writeInputs.detectChange(previousAssembly) : null;
|
|
339
342
|
let assembly = {
|
|
340
|
-
...withoutKeys(previousAssembly, ["source_package_material_fingerprint", "unchanged_output_reason", ...STAMP_FIELDS]),
|
|
343
|
+
...withoutKeys(previousAssembly, ["source_package_material_fingerprint", "unchanged_output_reason", "build_manifest", ...STAMP_FIELDS]),
|
|
341
344
|
...(buildEnvironment ? { evidence: { ...(isObject(previousAssembly.evidence) ? previousAssembly.evidence : {}), build_environment: buildEnvironment } } : {}),
|
|
342
345
|
stage: "assembly",
|
|
343
346
|
status: "completed",
|
|
344
347
|
build_fingerprint: fingerprint,
|
|
348
|
+
// The path + sha256 list the fingerprint hashes, so a later mismatch
|
|
349
|
+
// (doctor's built_output.fingerprint_stale, polish capture) can name the
|
|
350
|
+
// files that differ. Left out when it cannot be read back as this value.
|
|
351
|
+
...(manifest ? { build_manifest: manifest } : {}),
|
|
345
352
|
...(sourcePackageFingerprint ? { source_package_material_fingerprint: sourcePackageFingerprint } : {}),
|
|
346
353
|
...stamps,
|
|
347
354
|
completed_at: now,
|
|
@@ -375,9 +382,15 @@ function composeBuild(report, { now, recordedBy, fingerprint, buildEnvironment =
|
|
|
375
382
|
if (detected) {
|
|
376
383
|
const cause = detected === "spec_material_changed" ? "spec" : "presentation";
|
|
377
384
|
const demoted = demoteStages({ ...nextReport, stages: { ...nextReport.stages, assembly, ...deploy } }, { causes: [cause], now, by: "record build", only: ["polish", "qa"] });
|
|
378
|
-
|
|
385
|
+
const kept = keptPolishFor(demoted.report.stages.polish, { assembly, fingerprint, sourcePackageFingerprint, stamps });
|
|
386
|
+
return { report: kept ? { ...demoted.report, stages: { ...demoted.report.stages, polish: kept } } : demoted.report, context: null };
|
|
379
387
|
}
|
|
380
388
|
const previousPolish = stageObject(report, "polish");
|
|
389
|
+
// The usual #668 path: intake (prepare-build --force or record spec) already
|
|
390
|
+
// recorded the change on assembly, so nothing is detected here, but the
|
|
391
|
+
// carried input_change makes this build a replay the operator kept.
|
|
392
|
+
const keptPolish = keptPolishFor(previousPolish, { assembly, fingerprint, sourcePackageFingerprint, stamps });
|
|
393
|
+
if (keptPolish) return { report: { ...nextReport, stages: { ...nextReport.stages, assembly, polish: keptPolish, ...deploy } }, context: null };
|
|
381
394
|
const polishStillCurrent = stageIsTerminal(String(previousPolish.status || ""))
|
|
382
395
|
&& optionalString(previousPolish.source_build_fingerprint) === fingerprint;
|
|
383
396
|
const polish = polishStillCurrent
|
|
@@ -392,6 +405,16 @@ function composeBuild(report, { now, recordedBy, fingerprint, buildEnvironment =
|
|
|
392
405
|
return { report: { ...nextReport, stages: { ...nextReport.stages, assembly, polish, ...deploy } }, context: null };
|
|
393
406
|
}
|
|
394
407
|
|
|
408
|
+
// #668: the Polish a CampaignSpec change superseded comes back when the
|
|
409
|
+
// operator's --deviation-reason kept the output it reviewed (stage-ledger's
|
|
410
|
+
// restorePolishKeptWithOutput decides from the archived record). A terminal
|
|
411
|
+
// Polish, or a build the operator did not keep, is left alone.
|
|
412
|
+
function keptPolishFor(polish, { assembly, fingerprint, sourcePackageFingerprint, stamps }) {
|
|
413
|
+
if (!isObject(assembly.unchanged_output_reason) || stageIsTerminal(String(polish?.status || ""))) return null;
|
|
414
|
+
const kept = restorePolishKeptWithOutput(polish, { fingerprint, sourcePackageFingerprint, stamps, completedStatuses: POLISH_COMPLETED_STATUSES });
|
|
415
|
+
return kept ? withoutKeys(kept, ["skip_reason"]) : null;
|
|
416
|
+
}
|
|
417
|
+
|
|
395
418
|
// stages.deploy after a build of `fingerprint`, or null when it is kept: a
|
|
396
419
|
// completed deploy whose source_build_fingerprint names other output goes
|
|
397
420
|
// whole into history (archived_by "record build", reason_code
|
|
@@ -587,30 +610,143 @@ function composeTheme(report, { now, recordedBy, layer }) {
|
|
|
587
610
|
return { report: { ...report, theme }, context: null };
|
|
588
611
|
}
|
|
589
612
|
|
|
590
|
-
// The
|
|
591
|
-
//
|
|
592
|
-
//
|
|
593
|
-
// (origin
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
613
|
+
// The served preview a `record deploy` URL must name, as recorded (origin
|
|
614
|
+
// plus the packet's route root, "/<slug>/" or "/" for a root-served
|
|
615
|
+
// campaign), and the problems that refuse it. A local-serve packet names a
|
|
616
|
+
// loopback http(s) origin. A hosted packet names an https origin unless the
|
|
617
|
+
// host is loopback, where http is accepted too.
|
|
618
|
+
function deployPreviewUrl(packet, rawUrl) {
|
|
619
|
+
const hosted = !isLocalServePacket(packet);
|
|
620
|
+
if (hosted && !optionalString(packet?.deploy?.target)) {
|
|
621
|
+
return { url: null, hosted, problems: ["This packet records no deploy.target; set it (local-serve, or the hosting target) with " + `${cmd("qa")} policy set --packet <p> --deploy-target <target>.`] };
|
|
597
622
|
}
|
|
598
623
|
let url;
|
|
599
624
|
try {
|
|
600
625
|
url = new URL(String(rawUrl));
|
|
601
626
|
} catch {
|
|
602
|
-
return { url: null, problems: [`--base-url ${JSON.stringify(rawUrl)} is not a URL; give the served address, for example http://localhost:<port>/<slug
|
|
627
|
+
return { url: null, hosted, problems: [`--base-url ${JSON.stringify(rawUrl)} is not a URL; give the served address, for example ${hosted ? "https://<preview host>/<slug>/" : "http://localhost:<port>/<slug>/"}.`] };
|
|
603
628
|
}
|
|
604
629
|
const problems = [];
|
|
605
630
|
if (!/^https?:$/.test(url.protocol)) problems.push(`--base-url must be http or https (got ${url.protocol}).`);
|
|
606
|
-
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].`);
|
|
631
|
+
else if (!hosted && !isLoopbackHostname(url.hostname)) problems.push(`--base-url ${url.href} is not a loopback address; a local preview (deploy.target local-serve) is served on localhost, 127.0.0.1 or [::1].`);
|
|
632
|
+
else if (hosted && url.protocol !== "https:" && !isLoopbackHostname(url.hostname)) problems.push(`--base-url ${url.href} is plain http; a hosted deploy (deploy.target ${packet.deploy.target}) is recorded over https.`);
|
|
607
633
|
const routeRoot = campaignRouteRoot(packet);
|
|
608
634
|
if (!routeRoot) {
|
|
609
635
|
problems.push("The packet records no campaign.public_route_slug, so the served route root is unknown; record it first.");
|
|
610
636
|
} else if (`/${routeKey(url.pathname.replace(/\/index\.html?$/i, "/"))}/`.replace("//", "/") !== routeRoot) {
|
|
611
637
|
problems.push(`--base-url ${url.href} serves ${url.pathname}, but this campaign's route root is ${routeRoot}; give ${url.origin}${routeRoot}.`);
|
|
612
638
|
}
|
|
613
|
-
return { url: problems.length ? null : `${url.origin}${routeRoot}`, problems };
|
|
639
|
+
return { url: problems.length ? null : `${url.origin}${routeRoot}`, hosted, problems };
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function scriptSrcs(html) {
|
|
643
|
+
return [...html.matchAll(/<script\b[^>]*>/gi)]
|
|
644
|
+
.map((match) => (match[0].match(/\bsrc\s*=\s*["']([^"']+)["']/i) || [])[1])
|
|
645
|
+
.filter(Boolean);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
const sha256Of = (bytes) => `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
649
|
+
|
|
650
|
+
// The scripts and stylesheets the built pages load from the preview's own
|
|
651
|
+
// origin and the build output holds: each one's served URL, its file under
|
|
652
|
+
// _site/ (the host serves _site/ as its origin root) and that file's sha256.
|
|
653
|
+
// A reference to another origin, or to a path the build output does not hold,
|
|
654
|
+
// is not the build's to compare.
|
|
655
|
+
function builtAssets(site, pageUrls) {
|
|
656
|
+
const assets = new Map();
|
|
657
|
+
site.pages.forEach((page, index) => {
|
|
658
|
+
const html = readFileSync(page.built_path, "utf8");
|
|
659
|
+
const pageUrl = new URL(pageUrls[index]);
|
|
660
|
+
for (const ref of [...scriptSrcs(html), ...stylesheetHrefs(html)]) {
|
|
661
|
+
let served;
|
|
662
|
+
try {
|
|
663
|
+
served = new URL(ref, pageUrl);
|
|
664
|
+
} catch {
|
|
665
|
+
continue;
|
|
666
|
+
}
|
|
667
|
+
if (served.origin !== pageUrl.origin) continue;
|
|
668
|
+
served.hash = "";
|
|
669
|
+
let pathname;
|
|
670
|
+
try {
|
|
671
|
+
pathname = decodeURIComponent(served.pathname);
|
|
672
|
+
} catch {
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
675
|
+
const file = resolve(site.site_root, `.${pathname}`);
|
|
676
|
+
if (!file.startsWith(`${resolve(site.site_root)}${sep}`) || !existsSync(file) || !statSync(file).isFile()) continue;
|
|
677
|
+
if (!assets.has(served.href)) {
|
|
678
|
+
const bytes = readFileSync(file);
|
|
679
|
+
assets.set(served.href, { url: served.href, file, sha256: sha256Of(bytes), size: bytes.byteLength });
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
});
|
|
683
|
+
return [...assets.values()];
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// A file's sha256, or null when it cannot be read (removed, or replaced by a
|
|
687
|
+
// directory, since it was listed).
|
|
688
|
+
function fileSha256(file) {
|
|
689
|
+
try {
|
|
690
|
+
return sha256Of(readFileSync(file));
|
|
691
|
+
} catch {
|
|
692
|
+
return null;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// A served body, read no further than `limit` bytes: a response that says or
|
|
697
|
+
// streams more than the built file holds cannot be that file, so the read
|
|
698
|
+
// stops there instead of buffering whatever the host sends.
|
|
699
|
+
async function readCapped(response, limit) {
|
|
700
|
+
const declared = Number(response.headers?.get?.("content-length"));
|
|
701
|
+
if (Number.isFinite(declared) && declared > limit) {
|
|
702
|
+
await response.body?.cancel?.();
|
|
703
|
+
return { bytes: null, error: `Content-Length ${declared} exceeds the built file's ${limit} bytes` };
|
|
704
|
+
}
|
|
705
|
+
const reader = response.body?.getReader?.();
|
|
706
|
+
// No readable body is a transport failure, not an empty file, unless the
|
|
707
|
+
// built file is itself empty.
|
|
708
|
+
if (!reader) return limit === 0 ? { bytes: Buffer.alloc(0) } : { bytes: null, error: "the response had no readable body" };
|
|
709
|
+
const chunks = [];
|
|
710
|
+
let total = 0;
|
|
711
|
+
for (;;) {
|
|
712
|
+
const { done, value } = await reader.read();
|
|
713
|
+
if (done) break;
|
|
714
|
+
total += value.byteLength;
|
|
715
|
+
if (total > limit) {
|
|
716
|
+
await reader.cancel();
|
|
717
|
+
return { bytes: null, error: `served more than the built file's ${limit} bytes` };
|
|
718
|
+
}
|
|
719
|
+
chunks.push(Buffer.from(value));
|
|
720
|
+
}
|
|
721
|
+
return { bytes: Buffer.concat(chunks) };
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// One served asset, compared by bytes: it must answer 200, following
|
|
725
|
+
// redirects only within the preview's own origin as a page request does.
|
|
726
|
+
async function probeAsset(asset, fetchImpl) {
|
|
727
|
+
let current = asset.url;
|
|
728
|
+
try {
|
|
729
|
+
for (let hop = 0; hop <= PROBE_MAX_REDIRECTS; hop += 1) {
|
|
730
|
+
const response = await fetchImpl(current, { redirect: "manual", signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
|
|
731
|
+
const location = response.headers?.get?.("location");
|
|
732
|
+
if (response.status >= 300 && response.status < 400 && location) {
|
|
733
|
+
await response.body?.cancel?.();
|
|
734
|
+
const next = new URL(location, current);
|
|
735
|
+
if (next.origin !== new URL(asset.url).origin) return { ...asset, served: null, error: `redirects to ${next.href}, off this preview; nothing was requested there` };
|
|
736
|
+
current = next.href;
|
|
737
|
+
continue;
|
|
738
|
+
}
|
|
739
|
+
if (response.status !== 200) {
|
|
740
|
+
await response.body?.cancel?.();
|
|
741
|
+
return { ...asset, served: null, error: `HTTP ${response.status}` };
|
|
742
|
+
}
|
|
743
|
+
const { bytes, error } = await readCapped(response, asset.size);
|
|
744
|
+
return bytes ? { ...asset, served: sha256Of(bytes) } : { ...asset, served: null, error };
|
|
745
|
+
}
|
|
746
|
+
return { ...asset, served: null, error: `more than ${PROBE_MAX_REDIRECTS} redirects` };
|
|
747
|
+
} catch (error) {
|
|
748
|
+
return { ...asset, served: null, error: error?.name === "TimeoutError" ? `no answer within ${PROBE_TIMEOUT_MS / 1000} s` : String(error?.cause?.code || error?.message || error) };
|
|
749
|
+
}
|
|
614
750
|
}
|
|
615
751
|
|
|
616
752
|
const PROBE_TIMEOUT_MS = 5_000;
|
|
@@ -641,32 +777,56 @@ async function probePage(target, fetchImpl) {
|
|
|
641
777
|
|
|
642
778
|
/**
|
|
643
779
|
* What only the running server can say, read before the target lock (like
|
|
644
|
-
* the polish --evidence file): the URL names this campaign's
|
|
645
|
-
* and every built page under it answers 2xx
|
|
646
|
-
*
|
|
780
|
+
* the polish --evidence file): the URL names this campaign's served preview,
|
|
781
|
+
* and every built page under it answers (2xx on a local preview, 200 on a
|
|
782
|
+
* hosted one). On a hosted preview every script and stylesheet the built
|
|
783
|
+
* pages load from that origin must also be served byte-identical to the
|
|
784
|
+
* built output; HTML is not compared, since hosts inject markup. The lock
|
|
785
|
+
* re-checks the packet and the built output; the probe is never trusted for
|
|
786
|
+
* either.
|
|
647
787
|
*/
|
|
648
788
|
export async function probeLocalPreview({ packetPath, baseUrl, fetchImpl = globalThis.fetch }) {
|
|
649
789
|
const packet = readPacketFile("deploy", packetPath);
|
|
650
|
-
const { url, problems } =
|
|
790
|
+
const { url, hosted, problems } = deployPreviewUrl(packet, baseUrl);
|
|
651
791
|
if (problems.length) throw refuseRecord("deploy", problems);
|
|
652
792
|
const site = resolveBuiltSiteScope(targetRepoFor(packetPath, packet), { slug: packet.campaign.public_route_slug });
|
|
653
793
|
if (!site.ok) throw refuseRecord("deploy", [site.error]);
|
|
654
794
|
// The built pages, not the bare route root: a funnel often has no index
|
|
655
795
|
// page there, and servers answer that differently (404, a listing).
|
|
656
|
-
const
|
|
796
|
+
const pageUrls = site.pages.map((page) => (routeKey(page.route) ? new URL(`${routeKey(page.route)}/`, url).href : url));
|
|
797
|
+
const targets = [...new Set(pageUrls)];
|
|
657
798
|
// Requested together; the evidence keeps the built pages' order.
|
|
658
799
|
const routes = await Promise.all(targets.map((target) => probePage(target, fetchImpl)));
|
|
659
|
-
const
|
|
800
|
+
const answered = (status) => (hosted ? status === 200 : status >= 200 && status < 300);
|
|
801
|
+
const failed = routes.filter((route) => !answered(route.status));
|
|
660
802
|
if (failed.length) {
|
|
661
803
|
throw refuseRecord("deploy", [
|
|
662
804
|
...failed.map((route) => `${route.url}: ${route.status ? `HTTP ${route.status}` : route.error}.`),
|
|
663
|
-
|
|
805
|
+
hosted
|
|
806
|
+
? `Deploy the current _site/ build so every page answers HTTP 200 at ${url}, then run ${cmd("record")} deploy again.`
|
|
807
|
+
: `Serve the current _site/ build so every page answers at ${url}, then run ${cmd("record")} deploy again.`,
|
|
808
|
+
]);
|
|
809
|
+
}
|
|
810
|
+
// The build the probe ran against, so a record build before the lock
|
|
811
|
+
// refuses instead of recording a probe of other output.
|
|
812
|
+
const probedBuild = computeBuildFingerprint(site.campaign_dir);
|
|
813
|
+
const buildFingerprint = probedBuild.ok ? probedBuild.fingerprint : null;
|
|
814
|
+
if (!hosted) return { url, routes, buildFingerprint };
|
|
815
|
+
const assets = await Promise.all(builtAssets(site, pageUrls).map((asset) => probeAsset(asset, fetchImpl)));
|
|
816
|
+
const targetRepo = site.target_repo;
|
|
817
|
+
const differing = assets.filter((asset) => asset.served !== asset.sha256);
|
|
818
|
+
if (differing.length) {
|
|
819
|
+
throw refuseRecord("deploy", [
|
|
820
|
+
...differing.map((asset) => (asset.served
|
|
821
|
+
? `${asset.url}: served bytes (${asset.served}) differ from ${relative(targetRepo, asset.file)} (${asset.sha256}).`
|
|
822
|
+
: `${asset.url}: ${asset.error}; ${relative(targetRepo, asset.file)} is in the build output.`)),
|
|
823
|
+
`The preview at ${url} is not serving the recorded build. Deploy the current _site/ build, then run ${cmd("record")} deploy again.`,
|
|
664
824
|
]);
|
|
665
825
|
}
|
|
666
|
-
return { url, routes };
|
|
826
|
+
return { url, routes, buildFingerprint, assets: assets.map(({ url: assetUrl, file, sha256 }) => ({ url: assetUrl, file, sha256 })) };
|
|
667
827
|
}
|
|
668
828
|
|
|
669
|
-
// A recorded local
|
|
829
|
+
// A recorded preview (local or hosted): the packet's deploy.preview_url, and stages.deploy
|
|
670
830
|
// completed with the URL in outputs (what next reads), the probe as evidence,
|
|
671
831
|
// and the build it probed as source_build_fingerprint (doctorFacts has checked
|
|
672
832
|
// it equals stages.assembly.build_fingerprint), so a later record build of
|
|
@@ -678,7 +838,10 @@ function composeDeploy(report, packet, { now, recordedBy, probe, fingerprint })
|
|
|
678
838
|
status: "completed",
|
|
679
839
|
source_build_fingerprint: fingerprint,
|
|
680
840
|
outputs: [probe.url],
|
|
681
|
-
evidence:
|
|
841
|
+
evidence: [
|
|
842
|
+
...probe.routes.map((route) => `${route.url} answered HTTP ${route.status}`),
|
|
843
|
+
...(probe.assets || []).map((asset) => `${asset.url} served ${asset.sha256}, as built`),
|
|
844
|
+
],
|
|
682
845
|
completed_at: now,
|
|
683
846
|
recorded_by: recordedBy,
|
|
684
847
|
blockers: [],
|
|
@@ -808,6 +971,15 @@ function doctorFacts(stage, doctor, report, packet) {
|
|
|
808
971
|
};
|
|
809
972
|
}
|
|
810
973
|
|
|
974
|
+
// The manifest of the output doctor fingerprinted, read with doctor's own
|
|
975
|
+
// function over doctor's own root; null when the output on disk is no longer
|
|
976
|
+
// that value (assertOutputUnchanged then refuses the record).
|
|
977
|
+
function outputManifest(facts) {
|
|
978
|
+
if (!facts.fingerprint || !facts.fingerprintRoot) return null;
|
|
979
|
+
const current = computeBuildFingerprint(facts.fingerprintRoot);
|
|
980
|
+
return current.ok && current.fingerprint === facts.fingerprint ? buildManifestEntries(current) : null;
|
|
981
|
+
}
|
|
982
|
+
|
|
811
983
|
// The last check before the write: the output doctor fingerprinted is still
|
|
812
984
|
// the output on disk. The target lock keeps campaigns-os writers out, but a
|
|
813
985
|
// page-kit build does not take it, so the fingerprint is recomputed here with
|
|
@@ -824,12 +996,21 @@ function assertOutputUnchanged(stage, facts) {
|
|
|
824
996
|
// Under the lock, the probe is checked against the packet as it is now (it
|
|
825
997
|
// could have been retargeted while the probe ran), and the theme gate, which
|
|
826
998
|
// blocks deploy, must not be blocked.
|
|
827
|
-
function deployFacts(doctor, packet, probe) {
|
|
828
|
-
const { url, problems } =
|
|
999
|
+
function deployFacts(doctor, packet, probe, fingerprint) {
|
|
1000
|
+
const { url, problems } = deployPreviewUrl(packet, probe.url);
|
|
829
1001
|
if (problems.length) throw refuseRecord("deploy", problems);
|
|
830
1002
|
if (url !== probe.url) {
|
|
831
1003
|
throw refuseRecord("deploy", [`The packet's route root changed while the preview was probed (probed ${probe.url}, now ${url}); run ${cmd("record")} deploy again.`]);
|
|
832
1004
|
}
|
|
1005
|
+
// The served assets were compared with the files as they were before the
|
|
1006
|
+
// lock; a build since then is a refusal, not a record of other output.
|
|
1007
|
+
const rebuilt = (probe.assets || []).filter((asset) => fileSha256(asset.file) !== asset.sha256);
|
|
1008
|
+
if (rebuilt.length) {
|
|
1009
|
+
throw refuseRecord("deploy", [`The built output changed while the preview was probed (${rebuilt.map((asset) => asset.url).join(", ")}); run ${cmd("record")} build, then record deploy again.`]);
|
|
1010
|
+
}
|
|
1011
|
+
if (probe.buildFingerprint !== undefined && probe.buildFingerprint !== fingerprint) {
|
|
1012
|
+
throw refuseRecord("deploy", [`The built output changed while the preview was probed (probed ${probe.buildFingerprint || "no output"}, now ${fingerprint}); run ${cmd("record")} deploy again.`]);
|
|
1013
|
+
}
|
|
833
1014
|
const gate = doctor.derived?.theme_gate;
|
|
834
1015
|
if (gate?.status === "blocked") {
|
|
835
1016
|
throw refuseRecord("deploy", [
|
|
@@ -926,6 +1107,7 @@ export function recordStageCommand(args, { now = () => new Date(), beforeLock =
|
|
|
926
1107
|
] : [
|
|
927
1108
|
`stages.${stageKey}.status = ${composed.report.stages[stageKey].status}`,
|
|
928
1109
|
...(facts.fingerprint ? [`build output fingerprint ${facts.fingerprint} (doctor derived.build_output_fingerprint.value)`] : []),
|
|
1110
|
+
...(stage === "build" && Array.isArray(composed.report.stages.assembly.build_manifest) ? [`stages.assembly.build_manifest lists ${composed.report.stages.assembly.build_manifest.length} file(s), so a later fingerprint mismatch names the paths that differ`] : []),
|
|
929
1111
|
...(stage === "build" && buildEnvironment ? [`stages.assembly.evidence.build_environment = ${buildEnvironment}`] : []),
|
|
930
1112
|
...(stage === "build" && adapterDecisions ? Object.entries(adapterDecisions).map(([key, value]) => `report.adapter_decisions.${key} = ${value}`) : []),
|
|
931
1113
|
...(stage === "build" ? [`stages.polish.status = ${composed.report.stages.polish.status}`] : []),
|
|
@@ -942,7 +1124,8 @@ export function recordStageCommand(args, { now = () => new Date(), beforeLock =
|
|
|
942
1124
|
...(composed.context ? { context_path: contextPath } : {}),
|
|
943
1125
|
...(dryRun ? { would_write: writes } : { written: writes }),
|
|
944
1126
|
build_fingerprint: facts.fingerprint || null,
|
|
945
|
-
|
|
1127
|
+
// The manifest is kept in the report, not echoed: it lists every built file.
|
|
1128
|
+
record: stage === "theme" ? composed.report.theme : stage === "build" ? withoutKeys(composed.report.stages.assembly, ["build_manifest"]) : composed.report.stages[stageKey],
|
|
946
1129
|
...(stage === "build" ? { polish: composed.report.stages.polish } : {}),
|
|
947
1130
|
...(composed.context ? { scaffold: composed.context.scaffold } : {}),
|
|
948
1131
|
...(stage === "polish" && input.hasRepairLoopDefect ? { repair_loop_defect: input.repairLoopDefect } : {}),
|
|
@@ -998,11 +1181,11 @@ function recordUnderLock({ stage, packetPath, sidecars, lockedTarget, input, dry
|
|
|
998
1181
|
facts = doctorFacts(stage, doctor, report, packet);
|
|
999
1182
|
if (typeof afterDoctorRead === "function") afterDoctorRead();
|
|
1000
1183
|
if (stage === "theme") layer = brandLayerFacts(doctor);
|
|
1001
|
-
if (stage === "deploy") deployFacts(doctor, packet, input);
|
|
1184
|
+
if (stage === "deploy") deployFacts(doctor, packet, input, facts.fingerprint);
|
|
1002
1185
|
const next = stage === "setup"
|
|
1003
1186
|
? composeSetup(report, context, { now: timestamp, recordedBy })
|
|
1004
1187
|
: stage === "build"
|
|
1005
|
-
? composeBuild(report, { now: timestamp, recordedBy, fingerprint: facts.fingerprint, buildEnvironment, adapterDecisions, fallbackAdapterDecisions: adapterDecisions && !report.adapter_decisions ? readJsonIfExists(contextPath)?.adapter_decisions || packet.source_html?.adapter_contract : null, inputs: recordInputs(doctor), deviationReason })
|
|
1188
|
+
? composeBuild(report, { now: timestamp, recordedBy, fingerprint: facts.fingerprint, manifest: outputManifest(facts), buildEnvironment, adapterDecisions, fallbackAdapterDecisions: adapterDecisions && !report.adapter_decisions ? readJsonIfExists(contextPath)?.adapter_decisions || packet.source_html?.adapter_contract : null, inputs: recordInputs(doctor), deviationReason })
|
|
1006
1189
|
: stage === "theme"
|
|
1007
1190
|
? composeTheme(report, { now: timestamp, recordedBy, layer })
|
|
1008
1191
|
: stage === "deploy"
|
|
@@ -1010,7 +1193,7 @@ function recordUnderLock({ stage, packetPath, sidecars, lockedTarget, input, dry
|
|
|
1010
1193
|
: composePolish(report, { now: timestamp, recordedBy, fingerprint: facts.fingerprint, input, inputs: recordInputs(doctor) });
|
|
1011
1194
|
applyDerivedAssemblyReportSummary(next.report, recordInputs(doctor));
|
|
1012
1195
|
// The packet's one new value, deploy.preview_url, is checked by
|
|
1013
|
-
//
|
|
1196
|
+
// deployPreviewUrl; the rest of the packet is as the operator left it.
|
|
1014
1197
|
validateRecord(stage, { report: next.report, context: next.context, packet, fingerprint: facts.fingerprint });
|
|
1015
1198
|
assertOutputUnchanged(stage, facts);
|
|
1016
1199
|
composed = next;
|