@nextcommerce/campaigns-os 1.33.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/AGENTS.md +204 -0
- package/CHANGELOG.md +5002 -0
- package/CONTEXT.md +685 -0
- package/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +368 -0
- package/agents/claude/CLAUDE.md +32 -0
- package/agents/codex/AGENTS.md +27 -0
- package/agents/copilot/copilot-instructions.md +14 -0
- package/agents/cursor/campaigns-os.mdc +13 -0
- package/bin/campaigns-os.mjs +38 -0
- package/campaign-spec/README.md +138 -0
- package/campaign-spec/dist/analytics-vocabulary.d.ts +47 -0
- package/campaign-spec/dist/analytics-vocabulary.js +74 -0
- package/campaign-spec/dist/index.d.ts +40 -0
- package/campaign-spec/dist/index.js +77 -0
- package/campaign-spec/dist/normalize.d.ts +22 -0
- package/campaign-spec/dist/normalize.js +41 -0
- package/campaign-spec/dist/routing.d.ts +190 -0
- package/campaign-spec/dist/routing.js +263 -0
- package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +42 -0
- package/campaign-spec/dist/rules/analytics-contract-shape.js +303 -0
- package/campaign-spec/dist/rules/assembly-hints-shape.d.ts +42 -0
- package/campaign-spec/dist/rules/assembly-hints-shape.js +191 -0
- package/campaign-spec/dist/rules/campaign-metadata.d.ts +14 -0
- package/campaign-spec/dist/rules/campaign-metadata.js +40 -0
- package/campaign-spec/dist/rules/checkout-has-success-url.d.ts +31 -0
- package/campaign-spec/dist/rules/checkout-has-success-url.js +66 -0
- package/campaign-spec/dist/rules/cycle-detection.d.ts +12 -0
- package/campaign-spec/dist/rules/cycle-detection.js +141 -0
- package/campaign-spec/dist/rules/design-source-shape.d.ts +29 -0
- package/campaign-spec/dist/rules/design-source-shape.js +142 -0
- package/campaign-spec/dist/rules/downsell-without-upsell.d.ts +11 -0
- package/campaign-spec/dist/rules/downsell-without-upsell.js +40 -0
- package/campaign-spec/dist/rules/exit-intent-validation.d.ts +23 -0
- package/campaign-spec/dist/rules/exit-intent-validation.js +147 -0
- package/campaign-spec/dist/rules/funnel-count.d.ts +9 -0
- package/campaign-spec/dist/rules/funnel-count.js +37 -0
- package/campaign-spec/dist/rules/funnel-hypothesis-length.d.ts +22 -0
- package/campaign-spec/dist/rules/funnel-hypothesis-length.js +60 -0
- package/campaign-spec/dist/rules/funnel-identity.d.ts +15 -0
- package/campaign-spec/dist/rules/funnel-identity.js +57 -0
- package/campaign-spec/dist/rules/funnel-weight-sum.d.ts +20 -0
- package/campaign-spec/dist/rules/funnel-weight-sum.js +66 -0
- package/campaign-spec/dist/rules/index.d.ts +69 -0
- package/campaign-spec/dist/rules/index.js +131 -0
- package/campaign-spec/dist/rules/offer-ref-integrity.d.ts +13 -0
- package/campaign-spec/dist/rules/offer-ref-integrity.js +59 -0
- package/campaign-spec/dist/rules/package-pricing-sanity.d.ts +12 -0
- package/campaign-spec/dist/rules/package-pricing-sanity.js +42 -0
- package/campaign-spec/dist/rules/page-count.d.ts +11 -0
- package/campaign-spec/dist/rules/page-count.js +31 -0
- package/campaign-spec/dist/rules/page-id-uniqueness.d.ts +14 -0
- package/campaign-spec/dist/rules/page-id-uniqueness.js +47 -0
- package/campaign-spec/dist/rules/promo-code-input-validation.d.ts +8 -0
- package/campaign-spec/dist/rules/promo-code-input-validation.js +126 -0
- package/campaign-spec/dist/rules/promo-codes-shape.d.ts +30 -0
- package/campaign-spec/dist/rules/promo-codes-shape.js +187 -0
- package/campaign-spec/dist/rules/route-field-ignored-for-page-type.d.ts +33 -0
- package/campaign-spec/dist/rules/route-field-ignored-for-page-type.js +81 -0
- package/campaign-spec/dist/rules/route-target-resolves.d.ts +32 -0
- package/campaign-spec/dist/rules/route-target-resolves.js +112 -0
- package/campaign-spec/dist/rules/schema-version.d.ts +21 -0
- package/campaign-spec/dist/rules/schema-version.js +53 -0
- package/campaign-spec/dist/rules/sdk-version.d.ts +26 -0
- package/campaign-spec/dist/rules/sdk-version.js +96 -0
- package/campaign-spec/dist/rules/shipping-countries-shape.d.ts +10 -0
- package/campaign-spec/dist/rules/shipping-countries-shape.js +30 -0
- package/campaign-spec/dist/rules/shipping-methods-present.d.ts +10 -0
- package/campaign-spec/dist/rules/shipping-methods-present.js +26 -0
- package/campaign-spec/dist/rules/store-profile-shape.d.ts +30 -0
- package/campaign-spec/dist/rules/store-profile-shape.js +127 -0
- package/campaign-spec/dist/rules/thank-you-requirement.d.ts +16 -0
- package/campaign-spec/dist/rules/thank-you-requirement.js +50 -0
- package/campaign-spec/dist/rules/unknown-top-level-fields.d.ts +28 -0
- package/campaign-spec/dist/rules/unknown-top-level-fields.js +114 -0
- package/campaign-spec/dist/rules/upsell-has-packages.d.ts +9 -0
- package/campaign-spec/dist/rules/upsell-has-packages.js +35 -0
- package/campaign-spec/dist/rules/upsell-routing-complete.d.ts +10 -0
- package/campaign-spec/dist/rules/upsell-routing-complete.js +45 -0
- package/campaign-spec/dist/rules/upsell-without-checkout.d.ts +11 -0
- package/campaign-spec/dist/rules/upsell-without-checkout.js +44 -0
- package/campaign-spec/dist/rules/variant-labels-shape.d.ts +28 -0
- package/campaign-spec/dist/rules/variant-labels-shape.js +86 -0
- package/campaign-spec/dist/sdk-version-parse.d.ts +43 -0
- package/campaign-spec/dist/sdk-version-parse.js +62 -0
- package/campaign-spec/dist/types.d.ts +674 -0
- package/campaign-spec/dist/types.js +40 -0
- package/campaign-spec/package.json +12 -0
- package/compatibility.json +25 -0
- package/contracts/agent-relevant-change-policy.v1.json +111 -0
- package/contracts/brand-theme-source-defaults.figma-sections-export.v0.json +32 -0
- package/contracts/brand-theme-target-tokens.next-core.v0.json +65 -0
- package/contracts/campaign-cart-checkout-field-contract.v0.json +45 -0
- package/contracts/campaign-cart-sdk-support-policy.v0.json +11 -0
- package/contracts/commerce-surface-catalog.json +2452 -0
- package/contracts/fixtures/orientation/canonicalization/v1.json +34 -0
- package/contracts/fixtures/orientation/envelope/current.json +95 -0
- package/contracts/fixtures/orientation/envelope/freshness_unknown.json +97 -0
- package/contracts/fixtures/orientation/envelope/legacy_baseline.json +95 -0
- package/contracts/fixtures/orientation/envelope/orientation_available.json +136 -0
- package/contracts/fixtures/orientation/envelope/recovered_interrupted_update.json +136 -0
- package/contracts/fixtures/orientation/envelope/refused.json +100 -0
- package/contracts/fixtures/orientation/envelope/restart_required.json +137 -0
- package/contracts/fixtures/orientation/envelope/updated.json +135 -0
- package/contracts/fixtures/orientation/hostile-target/README.md +61 -0
- package/contracts/fixtures/orientation/hostile-target/manifest.json +82 -0
- package/contracts/fixtures/orientation/hostile-target/repo/CHANGELOG.md +18 -0
- package/contracts/fixtures/orientation/hostile-target/repo/bin/intended.mjs +12 -0
- package/contracts/fixtures/orientation/hostile-target/repo/bin/tripwire.mjs +15 -0
- package/contracts/fixtures/orientation/hostile-target/repo/contracts/release-ledger.json +48 -0
- package/contracts/fixtures/orientation/hostile-target/repo/contracts/supported-surface.json +17 -0
- package/contracts/fixtures/orientation/hostile-target/repo/docs/example-contract.md +13 -0
- package/contracts/fixtures/orientation/hostile-target/repo/hooks/post-checkout +5 -0
- package/contracts/fixtures/orientation/hostile-target/repo/hooks/post-merge +5 -0
- package/contracts/fixtures/orientation/hostile-target/repo/hooks/pre-commit +5 -0
- package/contracts/fixtures/orientation/hostile-target/repo/hostile-dependency-tripwire/package.json +15 -0
- package/contracts/fixtures/orientation/hostile-target/repo/hostile-dependency-tripwire/tripwire.mjs +9 -0
- package/contracts/fixtures/orientation/hostile-target/repo/package.json +20 -0
- package/contracts/fixtures/orientation/hostile-target/repo/schemas/example.v0.schema.json +15 -0
- package/contracts/fixtures/orientation/release-gate/cases.json +1073 -0
- package/contracts/fixtures/runtime-recipe/accept/current.json +299 -0
- package/contracts/fixtures/runtime-recipe/accept/minimal.json +294 -0
- package/contracts/fixtures/runtime-recipe/dist-states.json +51 -0
- package/contracts/fixtures/runtime-recipe/manifest.json +85 -0
- package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +297 -0
- package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/missing-required-field.json +251 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +310 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +299 -0
- package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/assembly-report.json +180 -0
- package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/build-context.json +115 -0
- package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/doctor-output.json +29 -0
- package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/qa-verdict.json +27 -0
- package/contracts/fixtures/sidecar-bundle/production-shaped/campaign-runtime.build.json +141 -0
- package/contracts/migration-sidecar-bundle.v0.json +149 -0
- package/contracts/orientation-limits.v1.json +41 -0
- package/contracts/orientation-reason-codes.v1.json +196 -0
- package/contracts/private-template-sources.json +8 -0
- package/contracts/release-ledger.json +4598 -0
- package/contracts/reserved-skill-names.json +13 -0
- package/contracts/runtime-recipe.campaigns-os-node-v1.json +299 -0
- package/contracts/supported-surface.json +180 -0
- package/contracts/template-brand-contract.apollo-mv-single-step.v0.json +27 -0
- package/contracts/template-brand-contract.apollo.v0.json +27 -0
- package/contracts/template-brand-contract.demeter.v0.json +27 -0
- package/contracts/template-brand-contract.olympus-mv-single-step.v0.json +27 -0
- package/contracts/template-brand-contract.olympus-mv-two-step.v0.json +28 -0
- package/contracts/template-brand-contract.olympus.v0.json +27 -0
- package/contracts/template-brand-contract.shared-commerce.v0.json +190 -0
- package/contracts/template-brand-contract.shop-single-step.v0.json +27 -0
- package/contracts/template-brand-contract.shop-three-step.v0.json +29 -0
- package/contracts/template-slot-manifest.apollo-mv-single-step.v0.json +14 -0
- package/contracts/template-slot-manifest.apollo.v0.json +12 -0
- package/contracts/template-slot-manifest.demeter.v0.json +30 -0
- package/contracts/template-slot-manifest.olympus-mv-single-step.v0.json +14 -0
- package/contracts/template-slot-manifest.olympus-mv-two-step.v0.json +15 -0
- package/contracts/template-slot-manifest.olympus.v0.json +12 -0
- package/contracts/template-slot-manifest.shared-content-core.v0.json +4254 -0
- package/contracts/template-slot-manifest.shop-single-step.v0.json +47 -0
- package/contracts/template-slot-manifest.shop-three-step.v0.json +33 -0
- package/docs/brand-theme-bridge.md +159 -0
- package/docs/build-packet.md +1300 -0
- package/docs/campaign-build-brief.md +145 -0
- package/docs/campaign-standardization-report.md +329 -0
- package/docs/campaigns-os-build-flow.md +117 -0
- package/docs/design-source-package.md +784 -0
- package/docs/legacy-migration.md +58 -0
- package/docs/migration-sidecar-bundle.md +139 -0
- package/docs/orientation-contract-reference.md +1220 -0
- package/docs/polish-evidence.md +502 -0
- package/docs/qa-and-test-orders.md +1691 -0
- package/docs/release-ledger-authoring-guide.md +274 -0
- package/docs/runtime-readiness.md +211 -0
- package/docs/supported-surface.md +83 -0
- package/docs/versioning.md +55 -0
- package/docs/workflow-findings-sidecar.md +588 -0
- package/package.json +135 -0
- package/prompts/first-build.md +27 -0
- package/prompts/friction-log.md +30 -0
- package/schemas/campaign-build-brief.v1.schema.json +149 -0
- package/schemas/campaign-design-source-package.v0.schema.json +697 -0
- package/schemas/campaign-runtime-assembly-report.v0.schema.json +390 -0
- package/schemas/campaign-runtime-build-context.v0.schema.json +339 -0
- package/schemas/campaign-runtime-build-packet.v0.schema.json +452 -0
- package/schemas/campaign-spec.v4.schema.json +582 -0
- package/schemas/campaigns-os-doctor-output.v0.schema.json +43 -0
- package/schemas/campaigns-os-legacy-migration-inventory.v0.schema.json +112 -0
- package/schemas/campaigns-os-legacy-provisioning-plan.v0.schema.json +38 -0
- package/schemas/campaigns-os-legacy-provisioning-receipt.v0.schema.json +57 -0
- package/schemas/campaigns-os-qa-verdict-sidecar.v0.schema.json +150 -0
- package/schemas/campaigns-os-qa-verdict.v0.schema.json +438 -0
- package/schemas/campaigns-os-release-ledger.v1.schema.json +162 -0
- package/schemas/campaigns-os-run-record.v0.schema.json +343 -0
- package/schemas/campaigns-os-runtime-recipe.v1.schema.json +313 -0
- package/schemas/campaigns-os-sidecar-bundle-conformance.v0.schema.json +78 -0
- package/schemas/campaigns-os-tooling-orientation.v1.schema.json +381 -0
- package/schemas/campaigns-os-workflow-finding.v0.schema.json +134 -0
- package/schemas/source-html-manifest.v0.schema.json +252 -0
- package/skills/next-campaigns-build/SKILL.md +73 -0
- package/skills/next-campaigns-os/SKILL.md +101 -0
- package/skills/next-campaigns-os/references/session-intake.md +160 -0
- package/skills/next-campaigns-os-setup/SKILL.md +22 -0
- package/skills/next-campaigns-polish/SKILL.md +145 -0
- package/skills/next-campaigns-qa/SKILL.md +92 -0
- package/skills.json +56 -0
- package/skills.sh +64 -0
- package/src/adapter-decision-contract.mjs +333 -0
- package/src/brand-theme.mjs +1151 -0
- package/src/browser-launch.mjs +79 -0
- package/src/build-brief.mjs +781 -0
- package/src/built-site-scope.mjs +312 -0
- package/src/campaign-ecosystem.mjs +734 -0
- package/src/campaign-identity.mjs +405 -0
- package/src/campaign-workspace.mjs +127 -0
- package/src/checkpoint-waiver.mjs +302 -0
- package/src/cli.mjs +13442 -0
- package/src/commercial-journey.mjs +1119 -0
- package/src/commercial-parity.mjs +965 -0
- package/src/consent.mjs +347 -0
- package/src/content-residue.mjs +322 -0
- package/src/deadline.mjs +81 -0
- package/src/design-source-package.mjs +2604 -0
- package/src/deviation.mjs +107 -0
- package/src/doctor-check-registry.mjs +49 -0
- package/src/doctor-sidecar.mjs +106 -0
- package/src/finding-cause.mjs +557 -0
- package/src/findings.mjs +326 -0
- package/src/fs-identity.mjs +68 -0
- package/src/gate-actions.mjs +105 -0
- package/src/html-scan.mjs +53 -0
- package/src/install-mode.mjs +272 -0
- package/src/legacy-migration.d.ts +128 -0
- package/src/legacy-migration.mjs +510 -0
- package/src/lifecycle.mjs +338 -0
- package/src/local-proof.mjs +401 -0
- package/src/map-pin-writeback.mjs +210 -0
- package/src/orchestration-stage-contract.mjs +81 -0
- package/src/package-install-fixture.mjs +33 -0
- package/src/page-kit-build-summary.mjs +175 -0
- package/src/page-kit-campaign-config.mjs +57 -0
- package/src/page-kit-sdk-version.mjs +392 -0
- package/src/page-kit-store-profile.mjs +369 -0
- package/src/page-kit-sync.mjs +162 -0
- package/src/polish-browser.mjs +867 -0
- package/src/polish-capture.mjs +1094 -0
- package/src/polish-deadline.mjs +51 -0
- package/src/polish-gate.mjs +739 -0
- package/src/polish-node.mjs +639 -0
- package/src/polish-page-load.mjs +1100 -0
- package/src/private-template-source.mjs +237 -0
- package/src/proof-policy.mjs +82 -0
- package/src/qa-analytics-correctness.mjs +307 -0
- package/src/qa-analytics-errors.mjs +38 -0
- package/src/qa-analytics-parity.mjs +699 -0
- package/src/qa-binding-evidence.mjs +140 -0
- package/src/qa-browser.mjs +6608 -0
- package/src/qa-cart-entry.mjs +406 -0
- package/src/qa-commercial-parity.mjs +641 -0
- package/src/qa-node.mjs +3620 -0
- package/src/qa-order-bump.mjs +381 -0
- package/src/qa-parity-capture.mjs +428 -0
- package/src/qa-parity-fixture.mjs +359 -0
- package/src/qa-publish.mjs +362 -0
- package/src/qa-purchase-data-layer.mjs +263 -0
- package/src/qa-route-probe.mjs +272 -0
- package/src/qa-sidecar.mjs +188 -0
- package/src/qa-test-order-topology.mjs +207 -0
- package/src/qa-url-privacy.mjs +13 -0
- package/src/qa-verdict-discovery.mjs +192 -0
- package/src/qa-verdict-publish.mjs +105 -0
- package/src/qa-verdict.mjs +287 -0
- package/src/remit.mjs +388 -0
- package/src/repo-scan.mjs +83 -0
- package/src/route-identity.mjs +133 -0
- package/src/run-record-closeout.mjs +229 -0
- package/src/run-record.mjs +839 -0
- package/src/run-session.mjs +226 -0
- package/src/runtime-state-ignore.mjs +113 -0
- package/src/sdk-attribute-index.mjs +212 -0
- package/src/sdk-markup.mjs +358 -0
- package/src/sdk-meta-tags.mjs +52 -0
- package/src/shell-token.mjs +7 -0
- package/src/sidecar-bundle.mjs +399 -0
- package/src/source-asset-crawl.mjs +469 -0
- package/src/source-html-intake.mjs +627 -0
- package/src/source-html-manifest.mjs +276 -0
- package/src/source-prep.mjs +284 -0
- package/src/spec-derive-store.mjs +431 -0
- package/src/spec-derive.mjs +514 -0
- package/src/spec-fetch.mjs +66 -0
- package/src/spec-hash.mjs +48 -0
- package/src/spec-identity.mjs +27 -0
- package/src/stage-ledger.mjs +509 -0
- package/src/standardization-report.mjs +1297 -0
- package/src/template-brand-contract.mjs +473 -0
- package/src/template-freshness.mjs +196 -0
- package/src/template-reference.mjs +81 -0
- package/src/template-slot-manifest.mjs +150 -0
- package/src/text-safety.mjs +62 -0
- package/src/theme-gate.mjs +185 -0
- package/src/upsell-selector-scope.mjs +299 -0
package/src/remit.mjs
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
// Shared remit rails for Campaigns OS. One helper POSTs a JSON payload to a
|
|
2
|
+
// path under the proxy base — the QA-verdict publish pattern, extracted so the
|
|
3
|
+
// QA verdict publish and Run Telemetry remit share exactly one fetch/try-catch.
|
|
4
|
+
// See docs/workflow-findings-sidecar.md (Remit Channel).
|
|
5
|
+
//
|
|
6
|
+
// `remit()` is the low-level transport: it throws on transport/HTTP errors,
|
|
7
|
+
// just as the original postVerdict did — the caller decides fatality.
|
|
8
|
+
// `remitRunRecord()` is the run-level wrapper: consent-gated, NON-FATAL (a
|
|
9
|
+
// failed or unreachable send never blocks or fails the run), and keyed on
|
|
10
|
+
// run_id. The keying is enforced by REFUSAL, not by replacement: this client
|
|
11
|
+
// only ever POSTs, and the receiver answers a second POST for a run_id it
|
|
12
|
+
// already holds with 409 run_record_conflict. So a run_id gets exactly one
|
|
13
|
+
// successful send — a send that never landed may be retried, one that landed
|
|
14
|
+
// cannot be revised. Callers that will close a run under an id must not spend
|
|
15
|
+
// that id on an interim record first. There is no background retry daemon — a
|
|
16
|
+
// dropped send is recorded locally, not queued.
|
|
17
|
+
//
|
|
18
|
+
// `remitRunRecord()` classifies the receiver's answer by HTTP status, not by
|
|
19
|
+
// "did anything throw": a 409 means the receiver already holds this run_id,
|
|
20
|
+
// which is the stored outcome the send was after (`already_stored`), and a 2xx
|
|
21
|
+
// whose body is not JSON is an acknowledgement whose payload could not be read
|
|
22
|
+
// (`ok_unparsed_ack`), not a refusal. Only a non-2xx other than 409, or a
|
|
23
|
+
// transport failure, is a failed send.
|
|
24
|
+
|
|
25
|
+
import { CANONICAL_REMIT_SCOPE, normalizeConsentScope } from "./consent.mjs";
|
|
26
|
+
import { runWithDeadline } from "./deadline.mjs";
|
|
27
|
+
|
|
28
|
+
export const DEFAULT_RUNS_ENDPOINT = "/api/runs";
|
|
29
|
+
export const DEFAULT_REMIT_TIMEOUT_MS = 10_000;
|
|
30
|
+
export const DEFAULT_REMIT_MAX_BODY_BYTES = 4_096;
|
|
31
|
+
|
|
32
|
+
// Hosts for which plain http is a local-development fact, not a network hop.
|
|
33
|
+
// Kept as the literal authority forms `new URL().hostname` produces (an IPv6
|
|
34
|
+
// literal keeps its brackets), so the check is an exact match, not a parse.
|
|
35
|
+
export const LOOPBACK_HOSTNAMES = Object.freeze(["localhost", "127.0.0.1", "[::1]"]);
|
|
36
|
+
|
|
37
|
+
export function isLoopbackHostname(hostname) {
|
|
38
|
+
return LOOPBACK_HOSTNAMES.includes(String(hostname));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function writeWarning(line) {
|
|
42
|
+
process.stderr.write(`[campaigns-os] ${line}\n`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The transport gate every credential-bearing request goes through before a
|
|
47
|
+
* socket is opened. A proxy base is only ever a trusted destination when TLS
|
|
48
|
+
* carries it: `X-Campaign-Key` on the remit rail and the ops admin key on the
|
|
49
|
+
* `telemetry list` rail are both request credentials, and a plain-http hop
|
|
50
|
+
* hands them to anything on the path.
|
|
51
|
+
*
|
|
52
|
+
* - `https:` — allowed, silently.
|
|
53
|
+
* - plain http to a loopback host — allowed for local receivers, with one loud
|
|
54
|
+
* stderr warning per request that the credential travels in clear.
|
|
55
|
+
* - anything else (plain http to a real host, a non-URL, an empty base) —
|
|
56
|
+
* THROWS, before any request is made. Nothing is sent.
|
|
57
|
+
*
|
|
58
|
+
* `credential` names what would travel, for both messages; it must name the
|
|
59
|
+
* credential's KIND, never its value. Pass `null` when the request attaches
|
|
60
|
+
* none (a QA verdict publish, say) — the wording then claims no credential
|
|
61
|
+
* rather than inventing one.
|
|
62
|
+
*
|
|
63
|
+
* Returns `{ url, base, loopback }`; `base` is the trailing-slash-trimmed
|
|
64
|
+
* string callers append their path to.
|
|
65
|
+
*/
|
|
66
|
+
export function assertSecureProxyBase(proxyBase, { label = "Remit", credential = "request credential", warn = writeWarning } = {}) {
|
|
67
|
+
const base = String(proxyBase ?? "").replace(/\/+$/, "");
|
|
68
|
+
let url;
|
|
69
|
+
try {
|
|
70
|
+
url = new URL(base);
|
|
71
|
+
} catch {
|
|
72
|
+
throw new Error(`${label}: --proxy-base is not a URL: ${base || "(empty)"}`);
|
|
73
|
+
}
|
|
74
|
+
const loopback = isLoopbackHostname(url.hostname);
|
|
75
|
+
if (url.protocol === "https:") return { url, base, loopback };
|
|
76
|
+
const subject = credential || "this request and its payload";
|
|
77
|
+
if (loopback) {
|
|
78
|
+
warn(`${label}: ${url.origin} is plain http — ${subject} ${credential ? "travels" : "travel"} in clear to a local proxy${credential ? "" : " (no credential is attached)"}. Use https for anything that is not a loopback receiver.`);
|
|
79
|
+
return { url, base, loopback };
|
|
80
|
+
}
|
|
81
|
+
throw new Error(`${label}: --proxy-base must be https (or a loopback host for local testing); declining to send ${subject} over ${url.protocol}//${url.host}.`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function byteLength(value) {
|
|
85
|
+
return Buffer.byteLength(String(value), "utf8");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function withTimeout(promise, timeoutMs, label, onTimeout = null) {
|
|
89
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return promise;
|
|
90
|
+
return runWithDeadline(() => promise, {
|
|
91
|
+
timeoutMs,
|
|
92
|
+
onTimeout,
|
|
93
|
+
timeoutError: () => new Error(`${label} timed out after ${timeoutMs}ms`),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function boundedResponseText(response, { maxBodyBytes = DEFAULT_REMIT_MAX_BODY_BYTES, timeoutMs = DEFAULT_REMIT_TIMEOUT_MS } = {}) {
|
|
98
|
+
const max = Number.isFinite(maxBodyBytes) && maxBodyBytes > 0 ? maxBodyBytes : DEFAULT_REMIT_MAX_BODY_BYTES;
|
|
99
|
+
|
|
100
|
+
if (response?.body && typeof response.body.getReader === "function") {
|
|
101
|
+
const reader = response.body.getReader();
|
|
102
|
+
const decoder = new TextDecoder();
|
|
103
|
+
let bytes = 0;
|
|
104
|
+
let text = "";
|
|
105
|
+
let truncated = false;
|
|
106
|
+
try {
|
|
107
|
+
while (true) {
|
|
108
|
+
const { done, value } = await withTimeout(reader.read(), timeoutMs, "Remit response read");
|
|
109
|
+
if (done) break;
|
|
110
|
+
const chunk = value instanceof Uint8Array ? value : Buffer.from(String(value));
|
|
111
|
+
const remaining = max - bytes;
|
|
112
|
+
if (remaining > 0) {
|
|
113
|
+
const slice = chunk.byteLength > remaining ? chunk.slice(0, remaining) : chunk;
|
|
114
|
+
text += decoder.decode(slice, { stream: true });
|
|
115
|
+
}
|
|
116
|
+
bytes += chunk.byteLength;
|
|
117
|
+
if (bytes > max) {
|
|
118
|
+
truncated = true;
|
|
119
|
+
await reader.cancel().catch(() => {});
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
text += decoder.decode();
|
|
124
|
+
} finally {
|
|
125
|
+
try {
|
|
126
|
+
reader.releaseLock?.();
|
|
127
|
+
} catch {
|
|
128
|
+
// Cleanup must not mask the transport or truncation error being reported.
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return truncated ? `${text}...[truncated to ${max} bytes]` : text;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const text = await withTimeout(response.text(), timeoutMs, "Remit response read");
|
|
135
|
+
if (byteLength(text) <= max) return text;
|
|
136
|
+
return `${String(text).slice(0, max)}...[truncated to ${max} bytes]`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* POST `payload` as JSON to `proxyBase` + `path`. Returns the parsed response
|
|
141
|
+
* body (or `{ ok: true }` for an empty 2xx). Throws on a non-2xx response or a
|
|
142
|
+
* transport error — mirrors qa-node.mjs postVerdict exactly — and also throws
|
|
143
|
+
* BEFORE any request when the proxy base fails `assertSecureProxyBase`.
|
|
144
|
+
* `label` and `credential` are passed straight to that gate: name the
|
|
145
|
+
* credential this request attaches, or `null` when it attaches none.
|
|
146
|
+
*/
|
|
147
|
+
// The request headers that carry a secret on these rails. The in-clear
|
|
148
|
+
// warning is inferred from these alone when a caller does not name its
|
|
149
|
+
// credential.
|
|
150
|
+
const CREDENTIAL_HEADER_NAMES = new Set(["x-campaign-key", "authorization", "x-api-key"]);
|
|
151
|
+
|
|
152
|
+
export async function remit(path, payload, proxyBase, {
|
|
153
|
+
fetchImpl = globalThis.fetch,
|
|
154
|
+
timeoutMs = DEFAULT_REMIT_TIMEOUT_MS,
|
|
155
|
+
maxBodyBytes = DEFAULT_REMIT_MAX_BODY_BYTES,
|
|
156
|
+
headers = {},
|
|
157
|
+
label = "Remit",
|
|
158
|
+
credential = undefined,
|
|
159
|
+
onResponse = null,
|
|
160
|
+
} = {}) {
|
|
161
|
+
if (typeof fetchImpl !== "function") {
|
|
162
|
+
throw new Error("Global fetch is not available. Upgrade to Node 18+ or pass fetchImpl.");
|
|
163
|
+
}
|
|
164
|
+
// What the gate says must match what this request actually carries. A caller
|
|
165
|
+
// that names its credential wins; otherwise infer it from the headers this
|
|
166
|
+
// module knows carry one (never from a bare Accept or Content-Type), so a
|
|
167
|
+
// credential-free POST (the QA verdict publish) is never described as
|
|
168
|
+
// leaking one.
|
|
169
|
+
const resolvedCredential = credential === undefined
|
|
170
|
+
? (Object.keys(headers).some((name) => CREDENTIAL_HEADER_NAMES.has(name.toLowerCase())) ? "the request credential" : null)
|
|
171
|
+
: credential;
|
|
172
|
+
const { base } = assertSecureProxyBase(proxyBase, { label, credential: resolvedCredential });
|
|
173
|
+
const suffix = String(path || "").startsWith("/") ? path : `/${path}`;
|
|
174
|
+
const controller = typeof AbortController === "function" ? new AbortController() : null;
|
|
175
|
+
let response;
|
|
176
|
+
response = await withTimeout(
|
|
177
|
+
fetchImpl(`${base}${suffix}`, {
|
|
178
|
+
method: "POST",
|
|
179
|
+
headers: { "Content-Type": "application/json", ...headers },
|
|
180
|
+
body: JSON.stringify(payload),
|
|
181
|
+
...(controller ? { signal: controller.signal } : {}),
|
|
182
|
+
}),
|
|
183
|
+
timeoutMs,
|
|
184
|
+
"Remit POST",
|
|
185
|
+
() => controller?.abort(),
|
|
186
|
+
);
|
|
187
|
+
// Observed before the body is judged, so a caller can record the status of
|
|
188
|
+
// an answer it accepted as well as one it did not.
|
|
189
|
+
if (typeof onResponse === "function") onResponse(response);
|
|
190
|
+
const body = await boundedResponseText(response, { maxBodyBytes, timeoutMs });
|
|
191
|
+
if (!response.ok) throw new RemitResponseError(`Remit POST failed: ${response.status} ${response.statusText} ${body}`, { response, body, reason: "http_error" });
|
|
192
|
+
if (!body) return { ok: true };
|
|
193
|
+
try {
|
|
194
|
+
return JSON.parse(body);
|
|
195
|
+
} catch {
|
|
196
|
+
// A 2xx the transport accepted but whose body is not the receiver's JSON
|
|
197
|
+
// answer — typically an intermediary's HTML page. The status is kept on
|
|
198
|
+
// the error so the caller can tell this apart from a refusal.
|
|
199
|
+
throw new RemitResponseError(`Remit POST ${response.status} ${response.statusText}: response body is not JSON: ${body}`, { response, body, reason: "unparsed_body" });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The error `remit()` throws for a response it received but could not accept:
|
|
205
|
+
* a non-2xx status (`reason: "http_error"`) or a 2xx whose body is not JSON
|
|
206
|
+
* (`reason: "unparsed_body"`). Carries the status so callers classify by it
|
|
207
|
+
* instead of by message text. A transport failure (refused connection,
|
|
208
|
+
* timeout, the proxy-base gate) is a plain Error with no status.
|
|
209
|
+
*/
|
|
210
|
+
export class RemitResponseError extends Error {
|
|
211
|
+
constructor(message, { response, body, reason }) {
|
|
212
|
+
super(message);
|
|
213
|
+
this.name = "RemitResponseError";
|
|
214
|
+
this.status = Number(response?.status) || null;
|
|
215
|
+
this.statusText = String(response?.statusText || "");
|
|
216
|
+
this.body = typeof body === "string" ? body : "";
|
|
217
|
+
this.reason = reason;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Remit outcomes reported in `result`. The first five are what
|
|
223
|
+
* `remitRunRecord()` read from a receiver it contacted; `not_contacted` is the
|
|
224
|
+
* run-record command's own value for a record whose remit is already `ok` on
|
|
225
|
+
* disk — the receiver holds it and was not asked again. A consent-off or
|
|
226
|
+
* disabled send reports `result: null`: nothing was sent and nothing is known.
|
|
227
|
+
*/
|
|
228
|
+
export const REMIT_RESULTS = Object.freeze({
|
|
229
|
+
stored: "stored",
|
|
230
|
+
already_stored: "already_stored",
|
|
231
|
+
ok_unparsed_ack: "ok_unparsed_ack",
|
|
232
|
+
refused: "refused",
|
|
233
|
+
transport_error: "transport_error",
|
|
234
|
+
not_contacted: "not_contacted",
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Where a remit resolves to, as a kind rather than a host: the canonical
|
|
239
|
+
* endpoint, a loopback receiver, or some other proxy the operator named. The
|
|
240
|
+
* Run Record carries this as `remit_base_kind`; the host itself never leaves
|
|
241
|
+
* the machine in the record.
|
|
242
|
+
*/
|
|
243
|
+
export const REMIT_BASE_KINDS = Object.freeze({
|
|
244
|
+
canonical: "canonical",
|
|
245
|
+
loopback: "loopback",
|
|
246
|
+
proxy: "proxy",
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
export function describeRemitBaseKind(proxyBase) {
|
|
250
|
+
if (normalizeConsentScope(proxyBase) === CANONICAL_REMIT_SCOPE) return REMIT_BASE_KINDS.canonical;
|
|
251
|
+
try {
|
|
252
|
+
return isLoopbackHostname(new URL(String(proxyBase)).hostname) ? REMIT_BASE_KINDS.loopback : REMIT_BASE_KINDS.proxy;
|
|
253
|
+
} catch {
|
|
254
|
+
return REMIT_BASE_KINDS.proxy;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// One bounded excerpt of a response body for an error string: enough to see
|
|
259
|
+
// what answered (a maintenance page, an error token), never the whole body.
|
|
260
|
+
function bodyExcerpt(body, max = 200) {
|
|
261
|
+
const flat = String(body ?? "").replace(/\s+/g, " ").trim();
|
|
262
|
+
return flat.length > max ? `${flat.slice(0, max)}...` : flat;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Classify what a run-record send came back with. Pure: takes the error
|
|
267
|
+
* `remit()` threw (or null for a parsed 2xx) and returns the status fields the
|
|
268
|
+
* caller stamps into the local record.
|
|
269
|
+
*
|
|
270
|
+
* - parsed 2xx → `stored`: ok, no error.
|
|
271
|
+
* - 409 → `already_stored`: ok, no error. The receiver holds a
|
|
272
|
+
* record for this run_id — the outcome the send was
|
|
273
|
+
* for, reached earlier (a retry after a crash, or a
|
|
274
|
+
* re-run). The body's error token does not change
|
|
275
|
+
* this: 409 on this endpoint means exactly one thing.
|
|
276
|
+
* - 2xx, non-JSON body → `ok_unparsed_ack`: ok, with an error string naming
|
|
277
|
+
* the status and an excerpt of what answered, so the
|
|
278
|
+
* anomaly is visible on the record.
|
|
279
|
+
* - other non-2xx → `refused`: not ok; error `Remit POST <status>: …`.
|
|
280
|
+
* - anything else → `transport_error`: not ok; the error's message.
|
|
281
|
+
*/
|
|
282
|
+
export function classifyRemitOutcome(error) {
|
|
283
|
+
// A parsed 2xx carries no error to read a status from; remitRunRecord fills
|
|
284
|
+
// http_status in from the response it observed.
|
|
285
|
+
if (!error) return { result: REMIT_RESULTS.stored, ok: true, error: null, http_status: null };
|
|
286
|
+
const status = error instanceof RemitResponseError ? error.status : null;
|
|
287
|
+
if (status === 409) {
|
|
288
|
+
return { result: REMIT_RESULTS.already_stored, ok: true, error: null, http_status: status };
|
|
289
|
+
}
|
|
290
|
+
if (error instanceof RemitResponseError && error.reason === "unparsed_body") {
|
|
291
|
+
return {
|
|
292
|
+
result: REMIT_RESULTS.ok_unparsed_ack,
|
|
293
|
+
ok: true,
|
|
294
|
+
error: `Remit POST ${status}: acknowledged with a body that is not JSON: ${bodyExcerpt(error.body) || "(empty)"}`,
|
|
295
|
+
http_status: status,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
if (status) {
|
|
299
|
+
return {
|
|
300
|
+
result: REMIT_RESULTS.refused,
|
|
301
|
+
ok: false,
|
|
302
|
+
error: `Remit POST ${status}: ${error.statusText || "refused"}${error.body ? ` ${bodyExcerpt(error.body)}` : ""}`.trimEnd(),
|
|
303
|
+
http_status: status,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
return { result: REMIT_RESULTS.transport_error, ok: false, error: error instanceof Error ? error.message : String(error), http_status: null };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Consent-gated, non-fatal remit of a Run Record. Returns a status object
|
|
311
|
+
* `{ attempted, ok, error, endpoint, result, http_status }` for the caller to
|
|
312
|
+
* stamp into the local record — a dropped send is visible, not silent.
|
|
313
|
+
*
|
|
314
|
+
* - Consent OFF (or unresolved) → no network call at all; the same six fields,
|
|
315
|
+
* with `attempted: false` and `result` / `http_status` null.
|
|
316
|
+
* - Network/HTTP error → swallowed; the run continues. `ok: false` + `error`.
|
|
317
|
+
* - The payload carries `run_id`. The receiver keeps one record per run_id and
|
|
318
|
+
* rejects a repeat POST for a stored id (409). That refusal is classified as
|
|
319
|
+
* `already_stored` — an ok outcome — so retrying a send whose first answer
|
|
320
|
+
* was lost converges on the stored record instead of recording a failure.
|
|
321
|
+
* - The body the receiver stores is stamped with the outcome it implies: a
|
|
322
|
+
* record the receiver holds is, by construction, one whose send landed, so
|
|
323
|
+
* the copy sent carries `remit_state: "ok"`, `remit_attempted: true`,
|
|
324
|
+
* `remit_ok: true` and the endpoint, not the pre-flight `pending` sentinel
|
|
325
|
+
* the local file carries until the answer arrives. The local record is not
|
|
326
|
+
* mutated here; the caller stamps it from the returned status.
|
|
327
|
+
*/
|
|
328
|
+
export async function remitRunRecord(record, {
|
|
329
|
+
proxyBase,
|
|
330
|
+
consent,
|
|
331
|
+
campaignKey = null,
|
|
332
|
+
endpoint = DEFAULT_RUNS_ENDPOINT,
|
|
333
|
+
fetchImpl = globalThis.fetch,
|
|
334
|
+
timeoutMs = DEFAULT_REMIT_TIMEOUT_MS,
|
|
335
|
+
maxBodyBytes = DEFAULT_REMIT_MAX_BODY_BYTES,
|
|
336
|
+
} = {}) {
|
|
337
|
+
if (!consent || consent.state !== "on") {
|
|
338
|
+
return { attempted: false, ok: null, error: null, endpoint: null, result: null, http_status: null };
|
|
339
|
+
}
|
|
340
|
+
// Tenant identity travels as a header, never in the body: the receiver
|
|
341
|
+
// stamps campaign_key_hash server-side from X-Campaign-Key and its
|
|
342
|
+
// tenant-scoped listing joins on that hash. A record remitted without the
|
|
343
|
+
// header is stored but invisible to every tenant scope (reachable only via
|
|
344
|
+
// the admin listing or by known run_id) — which is exactly how every record
|
|
345
|
+
// this CLI remitted before 2026-09-10 went dark once the receiver
|
|
346
|
+
// tenant-scoped its listing. Campaign keys are public-by-design.
|
|
347
|
+
const headers = typeof campaignKey === "string" && campaignKey.trim() ? { "X-Campaign-Key": campaignKey.trim() } : {};
|
|
348
|
+
const payload = stampRemittedCopy(record, endpoint, { baseKind: describeRemitBaseKind(proxyBase) });
|
|
349
|
+
let failure = null;
|
|
350
|
+
let httpStatus = null;
|
|
351
|
+
try {
|
|
352
|
+
await remit(endpoint, payload, proxyBase, {
|
|
353
|
+
fetchImpl,
|
|
354
|
+
timeoutMs,
|
|
355
|
+
maxBodyBytes,
|
|
356
|
+
headers,
|
|
357
|
+
label: "Run Telemetry remit",
|
|
358
|
+
credential: headers["X-Campaign-Key"] ? "the campaign key" : null,
|
|
359
|
+
onResponse: (response) => {
|
|
360
|
+
httpStatus = Number(response?.status) || null;
|
|
361
|
+
},
|
|
362
|
+
});
|
|
363
|
+
} catch (error) {
|
|
364
|
+
failure = error;
|
|
365
|
+
}
|
|
366
|
+
const outcome = classifyRemitOutcome(failure);
|
|
367
|
+
return { attempted: true, ok: outcome.ok, error: outcome.error, endpoint, result: outcome.result, http_status: outcome.http_status ?? httpStatus };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* The copy of `record` that goes over the wire: the same record, with its remit
|
|
372
|
+
* fields stating the outcome every stored record has by construction — a
|
|
373
|
+
* record the receiver holds is one whose send it stored (`remit_result:
|
|
374
|
+
* "stored"`), over the base kind this send resolved to. Exported so the wire
|
|
375
|
+
* shape is assertable without a receiver.
|
|
376
|
+
*/
|
|
377
|
+
export function stampRemittedCopy(record, endpoint = DEFAULT_RUNS_ENDPOINT, { baseKind = null } = {}) {
|
|
378
|
+
return {
|
|
379
|
+
...record,
|
|
380
|
+
remit_attempted: true,
|
|
381
|
+
remit_ok: true,
|
|
382
|
+
remit_error: null,
|
|
383
|
+
remit_endpoint: endpoint,
|
|
384
|
+
remit_state: "ok",
|
|
385
|
+
remit_result: REMIT_RESULTS.stored,
|
|
386
|
+
remit_base_kind: baseKind,
|
|
387
|
+
};
|
|
388
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Shared repository-scan helpers for the campaign scanners
|
|
2
|
+
// (campaign-ecosystem.mjs, standardization-report.mjs) and the brief
|
|
3
|
+
// extractor. One implementation of the file walk, the version compare and
|
|
4
|
+
// the small string helpers that used to be copied into each module.
|
|
5
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
6
|
+
import { join, relative, resolve, sep } from "node:path";
|
|
7
|
+
|
|
8
|
+
export function safeReadDir(dir) {
|
|
9
|
+
try {
|
|
10
|
+
return readdirSync(dir, { withFileTypes: true });
|
|
11
|
+
} catch {
|
|
12
|
+
return [];
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Directory names a scan never descends into: the caller's skip set, plus
|
|
17
|
+
// every dot-directory except the toolkit's own `.campaigns-os`.
|
|
18
|
+
export function shouldSkipDir(name, skipDirs = new Set()) {
|
|
19
|
+
return skipDirs.has(name) || (name.startsWith(".") && name !== ".campaigns-os");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Every regular file under `root`, sorted. `.git` and `node_modules` are
|
|
23
|
+
// never entered; the caller's skip set applies unless includeRuntime is set.
|
|
24
|
+
export function listFiles(root, { includeRuntime = false, skipDirs = new Set() } = {}) {
|
|
25
|
+
const files = [];
|
|
26
|
+
if (!existsSync(root) || !statSync(root).isDirectory()) return files;
|
|
27
|
+
const walk = (dir) => {
|
|
28
|
+
for (const entry of safeReadDir(dir)) {
|
|
29
|
+
if (entry.isDirectory()) {
|
|
30
|
+
if (!includeRuntime && shouldSkipDir(entry.name, skipDirs)) continue;
|
|
31
|
+
if (entry.name === ".git" || entry.name === "node_modules") continue;
|
|
32
|
+
walk(join(dir, entry.name));
|
|
33
|
+
} else if (entry.isFile()) {
|
|
34
|
+
files.push(join(dir, entry.name));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
walk(root);
|
|
39
|
+
return files.sort();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function extractVersion(value) {
|
|
43
|
+
const match = String(value || "").match(/(\d+)\.(\d+)\.(\d+)/);
|
|
44
|
+
if (!match) return null;
|
|
45
|
+
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function compareVersions(a, b) {
|
|
49
|
+
const left = extractVersion(a);
|
|
50
|
+
const right = extractVersion(b);
|
|
51
|
+
if (!left || !right) return 0;
|
|
52
|
+
for (let index = 0; index < 3; index += 1) {
|
|
53
|
+
const diff = left[index] - right[index];
|
|
54
|
+
if (diff !== 0) return diff > 0 ? 1 : -1;
|
|
55
|
+
}
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function normalizeString(value) {
|
|
60
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function isPlainObject(value) {
|
|
64
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
export function relPath(from, to) {
|
|
69
|
+
const rel = relative(resolve(from), resolve(to)).split(sep).join("/");
|
|
70
|
+
return rel || ".";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function rootId(targetRepo, rootPath) {
|
|
74
|
+
return relPath(targetRepo, rootPath).replace(/[^A-Za-z0-9_.-]+/g, "-") || ".";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function unique(values) {
|
|
78
|
+
return [...new Set(values.filter((value) => value !== null && value !== undefined && value !== ""))];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function escapeRegExp(value) {
|
|
82
|
+
return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
83
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Route identity: the served-route vocabulary every stage reads a campaign by.
|
|
2
|
+
// A campaign has one — `public_route_slug` (always required; the `_site/<slug>/`
|
|
3
|
+
// build directory) and `route_root` (where the funnel is SERVED: "/" for a
|
|
4
|
+
// root-served campaign, otherwise "/<slug>/") — and every stage that composes,
|
|
5
|
+
// strips or compares a route reads it from here, so no two stages can drift
|
|
6
|
+
// into two rules. A leaf on purpose: pure string work, no imports.
|
|
7
|
+
|
|
8
|
+
export function isAbsoluteHttpUrl(value) {
|
|
9
|
+
try {
|
|
10
|
+
const url = new URL(value);
|
|
11
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
12
|
+
} catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// The slug as identity: trimmed, no leading or trailing slashes, "" when absent.
|
|
18
|
+
export function normalizePublicRouteSlug(value) {
|
|
19
|
+
return String(value || "")
|
|
20
|
+
.trim()
|
|
21
|
+
.replace(/^\/+|\/+$/g, "");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// A page-kit route: `<segments>/` with no leading slash, no query, no fragment,
|
|
25
|
+
// no `.html`/`index.html`; "" when there is no route; an absolute http(s) URL
|
|
26
|
+
// is passed through untouched.
|
|
27
|
+
export function normalizePageKitRoute(value) {
|
|
28
|
+
const raw = String(value || "").trim();
|
|
29
|
+
if (!raw) return "";
|
|
30
|
+
if (isAbsoluteHttpUrl(raw)) return raw;
|
|
31
|
+
|
|
32
|
+
const clean = raw
|
|
33
|
+
.replace(/[?#].*$/, "")
|
|
34
|
+
.replace(/^\/+/, "")
|
|
35
|
+
.replace(/\/?index\.html$/i, "")
|
|
36
|
+
.replace(/\.html$/i, "")
|
|
37
|
+
.replace(/^\/+|\/+$/g, "");
|
|
38
|
+
|
|
39
|
+
return clean ? `${clean}/` : "";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The route with the slug prefix removed, when it carries one: "slug/checkout/"
|
|
43
|
+
// → "checkout/", "slug/" → "", "checkout/" → "checkout/".
|
|
44
|
+
export function stripPublicRoutePrefix(route, publicRouteSlug) {
|
|
45
|
+
const normalized = normalizePageKitRoute(route);
|
|
46
|
+
const slug = normalizePublicRouteSlug(publicRouteSlug);
|
|
47
|
+
if (!normalized || !slug) return normalized;
|
|
48
|
+
const clean = normalized.replace(/^\/+|\/+$/g, "");
|
|
49
|
+
if (clean === slug) return "";
|
|
50
|
+
if (clean.startsWith(`${slug}/`)) return `${clean.slice(slug.length + 1).replace(/\/?$/, "/")}`;
|
|
51
|
+
return normalized;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// A CampaignSpec route value reduced to the runtime-relative route the SDK
|
|
55
|
+
// composes onto the served root: the slug prefix stripped, and a nested value
|
|
56
|
+
// reduced to its terminal segment ("a/b/checkout/" → "checkout/").
|
|
57
|
+
export function runtimeRelativeRouteForSpecValue(value, publicRouteSlug) {
|
|
58
|
+
const normalized = normalizePageKitRoute(value);
|
|
59
|
+
if (!normalized) return "";
|
|
60
|
+
const stripped = stripPublicRoutePrefix(normalized, publicRouteSlug);
|
|
61
|
+
const segments = stripped.replace(/^\/+|\/+$/g, "").split("/").filter(Boolean);
|
|
62
|
+
if (segments.length > 1) return `${segments[segments.length - 1]}/`;
|
|
63
|
+
return stripped;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// The two ways a declared route_root is read, named by the artifact carrying
|
|
67
|
+
// it. Both return "/", "/<slug>/", or null for a value that is not honoured;
|
|
68
|
+
// neither ever roots a check on a foreign or malformed prefix.
|
|
69
|
+
//
|
|
70
|
+
// A packet carries the canonical form and nothing else (the shape its schema
|
|
71
|
+
// accepts), so only exactly "/" or "/<slug>/" is honoured. A near miss
|
|
72
|
+
// ("/slug", "//slug//", "/Slug/") is a hand edit doctor blocks by name; reading
|
|
73
|
+
// it more generously anywhere else would let one stage block and another pass
|
|
74
|
+
// the same packet.
|
|
75
|
+
export function packetRouteRoot(declared, publicRouteSlug) {
|
|
76
|
+
const slug = normalizePublicRouteSlug(publicRouteSlug);
|
|
77
|
+
const clean = typeof declared === "string" ? declared.trim() : "";
|
|
78
|
+
if (clean === "/") return "/";
|
|
79
|
+
if (slug && clean === `/${slug}/`) return clean;
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// A spec is intake: prepare-build canonicalises what it declares, so any
|
|
84
|
+
// spelling of the slug ("/slug", "slug/") reads as "/<slug>/"; only a foreign
|
|
85
|
+
// prefix is refused.
|
|
86
|
+
export function intakeRouteRoot(declared, publicRouteSlug) {
|
|
87
|
+
const slug = normalizePublicRouteSlug(publicRouteSlug);
|
|
88
|
+
const clean = typeof declared === "string" ? declared.trim() : "";
|
|
89
|
+
if (!clean) return null;
|
|
90
|
+
if (clean === "/") return "/";
|
|
91
|
+
if (slug && normalizePublicRouteSlug(clean) === slug) return `/${slug}/`;
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// The one resolver: the first declared route_root (packet, then spec, then raw
|
|
96
|
+
// spec) read under its own artifact's rule, else the slug-prefixed default
|
|
97
|
+
// "/<slug>/" (null with no slug). It says what it read and whether it was
|
|
98
|
+
// honoured, so a caller that records discards (QA) and one that only wants the
|
|
99
|
+
// root (doctor) share one answer:
|
|
100
|
+
// { route_root, declared, source: "packet" | "spec" | "raw_spec" | null,
|
|
101
|
+
// accepted: true | false | null } // null: nothing declared
|
|
102
|
+
// `publicRouteSlug` omitted (undefined) means "the packet's own slug"; passed
|
|
103
|
+
// explicitly — even null or "" — it is used as given, so a caller that resolved
|
|
104
|
+
// the slug from wider evidence (deploy path, spec) is never second-guessed by
|
|
105
|
+
// the packet, and an explicit "no slug" yields route_root null.
|
|
106
|
+
export function resolveRouteRoot({ packet = null, spec = null, rawSpec = null, publicRouteSlug } = {}) {
|
|
107
|
+
const slug = normalizePublicRouteSlug(publicRouteSlug === undefined ? packet?.campaign?.public_route_slug : publicRouteSlug);
|
|
108
|
+
const fallback = slug ? `/${slug}/` : null;
|
|
109
|
+
const declarations = [
|
|
110
|
+
["packet", packet?.campaign?.route_root, packetRouteRoot],
|
|
111
|
+
["spec", spec?.spec_identity?.route_root, intakeRouteRoot],
|
|
112
|
+
["spec", spec?.campaign?.route_root, intakeRouteRoot],
|
|
113
|
+
["raw_spec", rawSpec?.spec_identity?.route_root, intakeRouteRoot],
|
|
114
|
+
["raw_spec", rawSpec?.campaign?.route_root, intakeRouteRoot],
|
|
115
|
+
];
|
|
116
|
+
for (const [source, value, rule] of declarations) {
|
|
117
|
+
const declared = typeof value === "string" ? value.trim() : "";
|
|
118
|
+
if (!declared) continue;
|
|
119
|
+
const honoured = rule(declared, slug);
|
|
120
|
+
return { route_root: honoured ?? fallback, declared, source, accepted: honoured !== null };
|
|
121
|
+
}
|
|
122
|
+
return { route_root: fallback, declared: null, source: null, accepted: null };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// The packet's served root: "/" for a root-served campaign (whole funnel at
|
|
126
|
+
// site-root paths, no slug prefix), "/<slug>/" otherwise, null when neither is
|
|
127
|
+
// derivable. route_root only changes how SERVED routes are composed and
|
|
128
|
+
// validated; built output stays at _site/<public_route_slug>/. A declaration
|
|
129
|
+
// the packet rule does not honour falls through to the slug default, and
|
|
130
|
+
// doctor's validateRouteRootDeclaration raises the named blocker.
|
|
131
|
+
export function campaignRouteRoot(packet) {
|
|
132
|
+
return resolveRouteRoot({ packet }).route_root;
|
|
133
|
+
}
|