@nextcommerce/campaigns-os 1.53.0 → 1.54.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +1 -0
  3. package/compatibility.json +1 -1
  4. package/contracts/campaign-cart-checkout-field-contract.v0.json +10 -0
  5. package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
  6. package/contracts/commerce-surface-catalog.json +27 -27
  7. package/contracts/effects.v1.json +96 -37
  8. package/contracts/release-ledger.json +333 -0
  9. package/contracts/supported-surface.json +3 -3
  10. package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
  11. package/docs/build-packet.md +11 -4
  12. package/docs/campaign-standardization-report.md +32 -0
  13. package/docs/design-source-package.md +7 -3
  14. package/docs/effects.md +2 -1
  15. package/docs/local-setup.md +1 -1
  16. package/docs/orientation-contract-reference.md +1 -1
  17. package/docs/progress-snapshots.md +3 -1
  18. package/docs/qa-and-test-orders.md +28 -3
  19. package/docs/runtime-readiness.md +1 -1
  20. package/docs/sdk-storage-compatibility.md +15 -0
  21. package/docs/skills-revision.md +10 -10
  22. package/docs/supported-surface.md +1 -0
  23. package/package.json +1 -1
  24. package/schemas/campaign-runtime-assembly-report.v0.schema.json +5 -1
  25. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  26. package/skills/campaign-readback-classification/SKILL.md +3 -3
  27. package/skills/campaign-run-evidence/SKILL.md +3 -3
  28. package/skills/contribution-intake/SKILL.md +3 -3
  29. package/skills/next-campaigns-build/SKILL.md +3 -3
  30. package/skills/next-campaigns-os/SKILL.md +3 -3
  31. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  32. package/skills/next-campaigns-polish/SKILL.md +3 -3
  33. package/skills/next-campaigns-qa/SKILL.md +3 -3
  34. package/skills.json +10 -10
  35. package/src/campaign-ecosystem.mjs +153 -31
  36. package/src/cli.mjs +22 -3
  37. package/src/directory-lock.mjs +79 -7
  38. package/src/doctor/checks.mjs +12 -2
  39. package/src/doctor/inspect.mjs +13 -0
  40. package/src/invocation.mjs +3 -1
  41. package/src/progress-node.mjs +3 -2
  42. package/src/qa-browser.mjs +80 -9
  43. package/src/qa-node.mjs +33 -4
  44. package/src/sdk-attribute-index.mjs +111 -23
  45. package/src/sdk-markup.mjs +49 -10
  46. package/src/sdk-repin.mjs +282 -0
  47. package/src/stage-ledger.mjs +1 -1
  48. package/src/stage-record.mjs +37 -6
  49. package/src/standardization-report.mjs +26 -1
  50. package/src/target-lock.mjs +9 -1
@@ -2,6 +2,8 @@ import { existsSync, readFileSync, statSync } from "node:fs";
2
2
  import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
 
5
+ import { isReleasedSdkVersion } from "../campaign-spec/dist/index.js";
6
+ import { sdkVersionAtLeast } from "./sdk-attribute-index.mjs";
5
7
  import { shellToken } from "./shell-token.mjs";
6
8
  import {
7
9
  compareVersions,
@@ -40,6 +42,7 @@ const MAX_SAMPLE_COUNT = 8;
40
42
  // are still discovered — they just can't be evaluated against version policy.
41
43
  const LOADER_URL_PATTERN = /https?:\/\/[^"'\s]*campaign-cart@([^"'\s/]+)[^"'\s]*/g;
42
44
  const LOADER_ARTIFACT_PATTERN = /\/loader(?:\.min)?\.js\b|\/dist\//;
45
+ const LOADER_URL_INDEXED_PATTERN = new RegExp(LOADER_URL_PATTERN.source, "gd");
43
46
  const CAMPAIGN_ID_META_PATTERN = /<meta\s+[^>]*name=["']next-campaign-id["'][^>]*>/gi;
44
47
  const NEXT_CONFIG_PATTERN = /window\.nextConfig\s*=/;
45
48
  const DATA_NEXT_PATTERN = /\bdata-next-[a-zA-Z0-9_-]+/g;
@@ -180,18 +183,19 @@ export function scanCampaignCartAppRoot({
180
183
  const loader = collectLoaderReferences(root, [...htmlFiles, ...scriptFiles]);
181
184
  // Delivery can be a hosted loader URL OR a bundled npm dependency. When the
182
185
  // SDK ships as a dependency there is no loader ref to discover, but the pin
183
- // is still a real version signal — record it and feed a resolved semver into
184
- // version policy like a discovered version (source-distinguishable below).
186
+ // is still a real version signal — record it and feed its floor (the lowest
187
+ // version the spec lets npm install) into version policy like a discovered
188
+ // version (source-distinguishable below).
185
189
  const bundled = detectBundledSdkDependency(root);
186
190
  loader.bundled_dependency = bundled
187
- ? { name: bundled.name, version: bundled.version, resolved_version: bundled.resolved_version }
188
- : null;
189
- // A prerelease pin sorts strictly before its GA release, so it is never fed
190
- // into version policy as a clean semver — the numeric triple would wrongly
191
- // pass the release gate. It is recorded on sdk_loader and flagged instead.
192
- const bundledPolicyVersion = bundled?.resolved_version && !bundled.prerelease
193
- ? bundled.resolved_version
191
+ ? { name: bundled.name, version: bundled.version, resolved_version: bundled.resolved_version, floor_version: bundled.floor_version }
194
192
  : null;
193
+ // Policy asks whether every install meets the minimum, so only a floor the
194
+ // spec makes certain is evaluated. A prerelease pin sorts strictly before
195
+ // its GA release and has no released floor; nor does a range like <0.4.39
196
+ // or 0.4.41 || 0.4.18, whose extracted triple is not a version every
197
+ // install meets. Each is recorded on sdk_loader and flagged instead.
198
+ const bundledPolicyVersion = bundled?.floor_version || null;
195
199
  const versionEntries = [
196
200
  ...loader.versions.map((version) => ({ version, source: "loader" })),
197
201
  ...(bundledPolicyVersion && !loader.versions.includes(bundledPolicyVersion)
@@ -209,8 +213,20 @@ export function scanCampaignCartAppRoot({
209
213
  evidence: { name: bundled.name, version: bundled.version, resolved_version: bundled.resolved_version },
210
214
  next_action: "Pin the bundled campaign-cart dependency to a released version so version policy can be evaluated.",
211
215
  }));
216
+ } else if (bundled && !bundled.floor_version) {
217
+ findings.push(finding({
218
+ severity: "warning",
219
+ category: "standardization_warning",
220
+ code: "version.sdk_dependency_floor_unknown",
221
+ message: `Bundled Campaign Cart dependency ${bundled.name}@${bundled.version} does not fix a lowest version (an exact pin, or one ^, ~ or >= range on a released version); version policy cannot be evaluated against it.`,
222
+ confidence: "static_contract",
223
+ evidence: { name: bundled.name, version: bundled.version, resolved_version: bundled.resolved_version },
224
+ next_action: "Pin the bundled campaign-cart dependency exactly, or to a ^, ~ or >= range on a supported release, so version policy can be evaluated.",
225
+ }));
212
226
  }
213
- const checkoutFields = inspectCheckoutFields(root, htmlFiles, contract, findings);
227
+ const checkoutFields = inspectCheckoutFields(root, htmlFiles, contract, findings, {
228
+ sdkVersionForFile: loaderSdkVersionResolver(loader, bundled),
229
+ });
214
230
  const payment = inspectPaymentSurfaces(root, htmlFiles, scriptFiles, findings);
215
231
  const dataNext = inspectDataNext(root, htmlFiles);
216
232
  const runtime = inspectRuntimeArtifacts(root, target, findings);
@@ -280,6 +296,8 @@ export function scanCampaignCartAppRoot({
280
296
  // when no such dependency is declared. A prerelease pin sorts strictly BEFORE
281
297
  // its GA release in semver, so `prerelease: true` tells the caller the pin
282
298
  // must NOT be evaluated against the release policy as if it were the release.
299
+ // `floor_version` is the lowest version the spec allows when that is certain
300
+ // (bundledSdkFloor), else null; it is the version policy evaluates.
283
301
  function detectBundledSdkDependency(root) {
284
302
  const pkg = readJson(join(root, "package.json"));
285
303
  const deps = { ...(pkg?.dependencies || {}), ...(pkg?.devDependencies || {}) };
@@ -291,12 +309,23 @@ function detectBundledSdkDependency(root) {
291
309
  name,
292
310
  version: spec,
293
311
  resolved_version: semver ? `${semver[1]}${semver[2] || ""}` : null,
312
+ floor_version: bundledSdkFloor(spec),
294
313
  prerelease: Boolean(semver && semver[2]),
295
314
  };
296
315
  }
297
316
  return null;
298
317
  }
299
318
 
319
+ // The lowest SDK version a bundled dependency spec allows, when the spec makes
320
+ // it certain: an exact pin (0.4.39, =0.4.39, v0.4.39) or one lower-bounded
321
+ // comparator (^0.4.39, ~0.4.39, >=0.4.39), each on a released version. Any
322
+ // other spec (<0.4.39, a ||, hyphen or x-range, a tag, a prerelease) gives
323
+ // null: the version extracted from it is not a version every install meets.
324
+ function bundledSdkFloor(spec) {
325
+ const match = /^\s*(?:\^|~|>=|=)?\s*v?(\S+)\s*$/.exec(String(spec ?? ""));
326
+ return match && isReleasedSdkVersion(match[1]) ? match[1] : null;
327
+ }
328
+
300
329
  export function detectFrameworks(root) {
301
330
  const pkg = readJson(join(root, "package.json"));
302
331
  const deps = { ...(pkg?.dependencies || {}), ...(pkg?.devDependencies || {}) };
@@ -309,16 +338,13 @@ function collectLoaderReferences(root, files) {
309
338
  for (const file of files) {
310
339
  const raw = safeReadText(file);
311
340
  if (raw === null) continue;
312
- const content = HTML_EXTENSIONS.has(extname(file).toLowerCase()) ? maskHtmlComments(raw) : raw;
313
- for (const match of content.matchAll(withGlobal(LOADER_URL_PATTERN))) {
314
- if (!LOADER_ARTIFACT_PATTERN.test(match[0])) continue;
315
- const semver = match[1].match(/^v?(\d+\.\d+\.\d+)$/);
341
+ for (const entry of campaignCartArtifactReferences(raw, { isHtml: HTML_EXTENSIONS.has(extname(file).toLowerCase()) })) {
316
342
  references.push({
317
343
  path: relPath(root, file),
318
- line: lineOf(content, match.index || 0),
319
- url: match[0],
320
- ref: match[1],
321
- version: semver ? semver[1] : null,
344
+ line: entry.line,
345
+ url: entry.url,
346
+ ref: entry.ref,
347
+ version: entry.version,
322
348
  });
323
349
  }
324
350
  }
@@ -328,6 +354,73 @@ function collectLoaderReferences(root, files) {
328
354
  };
329
355
  }
330
356
 
357
+ // Every file the scan walks under `root`: its own skipped directories (.git,
358
+ // .campaign-runtime, _site, node_modules, qa-output, dist, build) left out.
359
+ export const listCampaignCartScanFiles = (root) => listFiles(root);
360
+
361
+ // The HTML and script files among them, the ones the scan reads.
362
+ export function listCampaignCartSourceFiles(root) {
363
+ return listFiles(root).filter((file) => {
364
+ const ext = extname(file).toLowerCase();
365
+ return HTML_EXTENSIONS.has(ext) || SCRIPT_EXTENSIONS.has(ext);
366
+ });
367
+ }
368
+
369
+ export const isCampaignCartHtmlFile = (file) => HTML_EXTENSIONS.has(extname(file).toLowerCase());
370
+
371
+ // Every Campaign Cart loader/dist reference in one file's text, in order, read
372
+ // exactly as the scan reads it: HTML comments masked in an HTML file, and only
373
+ // URLs that point at a loader or dist artifact. `ref` is the segment after
374
+ // `campaign-cart@` and `version` its x.y.z when the ref is a plain semver pin
375
+ // (with or without a leading v), else null. `url_start` is the URL's offset
376
+ // and `ref_start`/`ref_end` the offsets of that segment in the file text; masking preserves offsets, so they
377
+ // index the unmasked text too. `sdk repin` rewrites only that span.
378
+ export function campaignCartArtifactReferences(text, { isHtml = false } = {}) {
379
+ const content = isHtml ? maskHtmlComments(text) : String(text ?? "");
380
+ const references = [];
381
+ for (const match of content.matchAll(LOADER_URL_INDEXED_PATTERN)) {
382
+ if (!LOADER_ARTIFACT_PATTERN.test(match[0])) continue;
383
+ const semver = match[1].match(/^v?(\d+\.\d+\.\d+)$/);
384
+ references.push({
385
+ line: lineOf(content, match.index || 0),
386
+ url: match[0],
387
+ ref: match[1],
388
+ version: semver ? semver[1] : null,
389
+ url_start: match.index,
390
+ ref_start: match.indices[1][0],
391
+ ref_end: match.indices[1][1],
392
+ });
393
+ }
394
+ return references;
395
+ }
396
+
397
+ // The SDK version each HTML file's checkout bindings run against, for the
398
+ // version-gated field aliases. A file that loads the SDK itself uses its own
399
+ // loader pin: one exact version, or unknown when its refs are unpinned or
400
+ // disagree. Any other file (a partial, a page whose loader is injected) uses
401
+ // the campaign's version: the lowest of the loader pins and the bundled
402
+ // dependency's floor, or unknown when any loader ref is unpinned, the bundled
403
+ // dependency has no certain floor, or nothing is discovered. Unknown is judged
404
+ // as an earlier SDK (see inspectCheckoutFields).
405
+ function loaderSdkVersionResolver(loader, bundled) {
406
+ const byFile = new Map();
407
+ for (const ref of loader.references) {
408
+ if (!byFile.has(ref.path)) byFile.set(ref.path, []);
409
+ byFile.get(ref.path).push(ref.version);
410
+ }
411
+ const floor = bundled?.floor_version || null;
412
+ const versions = [...loader.versions, ...(floor ? [floor] : [])];
413
+ const campaign = loader.references.some((ref) => !ref.version) || (bundled && !floor) || !versions.length
414
+ ? null
415
+ : { version: [...versions].sort(compareVersions)[0], source: "campaign" };
416
+ return (path) => {
417
+ const own = byFile.get(path);
418
+ if (!own) return campaign;
419
+ const versions = unique(own);
420
+ return own.every(Boolean) && versions.length === 1 ? { version: versions[0], source: "loader" } : null;
421
+ };
422
+ }
423
+
331
424
  export function evaluateVersionPolicy(versionEntries, policy, findings) {
332
425
  const evaluations = versionEntries.map(({ version, source }) => ({
333
426
  version,
@@ -364,7 +457,15 @@ export function evaluateVersionPolicy(versionEntries, policy, findings) {
364
457
  };
365
458
  }
366
459
 
367
- export function inspectCheckoutFields(root, htmlFiles, contract, findings) {
460
+ // `sdkVersionForFile(relativePath)` returns the { version, source } a file's
461
+ // bindings run against, or null when it is unknown. A version-gated alias
462
+ // (contract version_gated_aliases) is supported only on an exact SDK release
463
+ // at or after its since-version; on an earlier or unknown version it is
464
+ // classified as the contract classifies it without the gate (a stale alias,
465
+ // or unknown). Unknown is the conservative side: the canonical name works on
466
+ // every version, and accepting the alias there could pass a field that never
467
+ // reaches the order.
468
+ export function inspectCheckoutFields(root, htmlFiles, contract, findings, { sdkVersionForFile = null } = {}) {
368
469
  const bindings = [];
369
470
  const attributes = contract?.binding_attributes || ["data-next-checkout-field", "os-checkout-field"];
370
471
  const attributePattern = new RegExp(`\\b(${attributes.map(escapeRegExp).join("|")})\\s*=\\s*(["'])([^"']*)\\2`, "g");
@@ -372,17 +473,22 @@ export function inspectCheckoutFields(root, htmlFiles, contract, findings) {
372
473
  const raw = safeReadText(file);
373
474
  if (raw === null) continue;
374
475
  const content = maskHtmlComments(raw);
476
+ const path = relPath(root, file);
477
+ const sdk = (typeof sdkVersionForFile === "function" ? sdkVersionForFile(path) : null) || { version: null, source: null };
375
478
  for (const match of content.matchAll(attributePattern)) {
376
479
  const value = match[3].trim();
377
- const verdict = classifyFieldBinding(value, contract);
480
+ const verdict = classifyFieldBinding(value, contract, sdk.version);
378
481
  bindings.push({
379
482
  attribute: match[1],
380
483
  value,
381
- path: relPath(root, file),
484
+ path,
382
485
  line: lineOf(content, match.index || 0),
383
486
  supported: verdict.classification === "supported",
384
487
  classification: verdict.classification,
385
488
  canonical: verdict.canonical,
489
+ since: verdict.since,
490
+ sdk_version: sdk.version,
491
+ sdk_version_source: sdk.source,
386
492
  });
387
493
  }
388
494
  }
@@ -391,16 +497,28 @@ export function inspectCheckoutFields(root, htmlFiles, contract, findings) {
391
497
  const unknown = bindings.filter((entry) => entry.classification === "unknown");
392
498
 
393
499
  if (unsupported.length) {
500
+ const gated = unsupported.filter((entry) => entry.since);
501
+ const gatedNote = gated.length
502
+ ? ` The SDK consumes ${unique(gated.map((entry) => `${entry.value} only from ${entry.since}`)).join(", ")}; these bindings run on SDK ${unique(gated.map((entry) => entry.sdk_version || "of unknown version (judged as an earlier SDK)")).join(", ")}.`
503
+ : "";
394
504
  findings.push(finding({
395
505
  severity: "blocker",
396
506
  category: "standardization_blocker",
397
507
  code: "checkout.unsupported_field_binding",
398
- message: `${unsupported.length} checkout field binding(s) use stale aliases the Campaign Cart SDK does not consume (contract: ${contract?.schema_version || "unknown"}).`,
508
+ message: `${unsupported.length} checkout field binding(s) use stale aliases the Campaign Cart SDK does not consume (contract: ${contract?.schema_version || "unknown"}).${gatedNote}`,
399
509
  confidence: "static_contract",
400
510
  evidence: dedupeBy(unsupported, (entry) => entry.value)
401
- .map((entry) => ({ value: entry.value, canonical: entry.canonical, path: entry.path, line: entry.line }))
511
+ .map((entry) => ({
512
+ value: entry.value,
513
+ canonical: entry.canonical,
514
+ path: entry.path,
515
+ line: entry.line,
516
+ ...(entry.since ? { since: entry.since, sdk_version: entry.sdk_version } : {}),
517
+ }))
402
518
  .slice(0, MAX_SAMPLE_COUNT),
403
- next_action: "Rewrite each stale alias to its canonical Campaign Cart field name, then prove checkout binding in browser QA.",
519
+ next_action: gated.length
520
+ ? "Rewrite each stale alias to its canonical Campaign Cart field name (for an alias with a since-version in the evidence, pinning the SDK at or after that version also works), then prove checkout binding in browser QA."
521
+ : "Rewrite each stale alias to its canonical Campaign Cart field name, then prove checkout binding in browser QA.",
404
522
  }));
405
523
  }
406
524
  if (unknown.length) {
@@ -411,7 +529,7 @@ export function inspectCheckoutFields(root, htmlFiles, contract, findings) {
411
529
  message: `${unknown.length} checkout field binding(s) are outside the known Campaign Cart field contract.`,
412
530
  confidence: "static_inference",
413
531
  evidence: dedupeBy(unknown, (entry) => entry.value)
414
- .map((entry) => ({ value: entry.value, path: entry.path, line: entry.line }))
532
+ .map((entry) => ({ value: entry.value, path: entry.path, line: entry.line, ...(entry.since ? { since: entry.since, sdk_version: entry.sdk_version } : {}) }))
415
533
  .slice(0, MAX_SAMPLE_COUNT),
416
534
  next_action: "Confirm the field against the Campaign Cart SDK before treating it as supported or repairing it.",
417
535
  }));
@@ -425,10 +543,11 @@ export function inspectCheckoutFields(root, htmlFiles, contract, findings) {
425
543
  };
426
544
  }
427
545
 
428
- function classifyFieldBinding(value, contract) {
429
- if (!value) return { classification: "unknown", canonical: null };
546
+ function classifyFieldBinding(value, contract, sdkVersion = null) {
547
+ if (!value) return { classification: "unknown", canonical: null, since: null };
430
548
  const canonicalFields = new Set(contract?.canonical_fields || []);
431
549
  const acceptedAliases = contract?.accepted_aliases || {};
550
+ const gatedAliases = contract?.version_gated_aliases || {};
432
551
  const staleAliases = contract?.stale_aliases || {};
433
552
  const prefixes = contract?.prefixes || [];
434
553
 
@@ -441,12 +560,15 @@ function classifyFieldBinding(value, contract) {
441
560
  break;
442
561
  }
443
562
  }
444
- if (canonicalFields.has(base)) return { classification: "supported", canonical: null };
445
- if (typeof acceptedAliases[base] === "string") return { classification: "supported", canonical: null };
563
+ if (canonicalFields.has(base)) return { classification: "supported", canonical: null, since: null };
564
+ if (typeof acceptedAliases[base] === "string") return { classification: "supported", canonical: null, since: null };
565
+ const gated = Object.hasOwn(gatedAliases, base) ? gatedAliases[base] : null;
566
+ const since = typeof gated?.since === "string" ? gated.since : null;
567
+ if (since && sdkVersionAtLeast(sdkVersion, since)) return { classification: "supported", canonical: null, since };
446
568
  if (typeof staleAliases[base] === "string") {
447
- return { classification: "stale_alias", canonical: `${prefix}${staleAliases[base]}` };
569
+ return { classification: "stale_alias", canonical: `${prefix}${staleAliases[base]}`, since };
448
570
  }
449
- return { classification: "unknown", canonical: null };
571
+ return { classification: "unknown", canonical: null, since };
450
572
  }
451
573
 
452
574
  function inspectPaymentSurfaces(root, htmlFiles, scriptFiles, findings) {
package/src/cli.mjs CHANGED
@@ -318,9 +318,10 @@ Usage:
318
318
  [--allow-uncertified-template "<reason>"] [--order-path-depth <off|common|full>]
319
319
  [--deploy-target <target>] [--preview-url <url>] [--production-url <url>] [--no-run-session] [--force] # intake alias for prepare-build + doctor
320
320
  campaigns-os doctor --packet <campaign-runtime.build.json> [--context <json>] [--report <json>] [--strip-paths] [--write] [--no-write] [--doctor-out <path>] [--proxy-base <url>] [--no-live-refs] [--json] # inspection by default; --doctor-out requires --write; --no-write wins. When the packet's built _site/<route>/ exists and a public Campaigns API key resolves (packet, its local CampaignSpec, or the declared campaign-key env var), doctor makes one read-only GET of {proxy-base}/api/campaign under X-Campaign-Key to check each built page's shipping and package refs against the live campaign; --proxy-base overrides the canonical proxy (https, or a loopback host over http). --no-live-refs skips the read and records not_run with reason disabled. No key, no built page, or a failed read records derived.live_campaign_refs as not_run with its reason. Only doctor and qa run make this read; other commands that run doctor record not_read
321
- campaigns-os doctor --built <page-kit-target-repo> --family <family> [--slug <slug>] [--base-url <url>] [--emit-packet [path]] [--json] # L7: doctor a built _site/ with no Build Packet
321
+ campaigns-os doctor --built <page-kit-target-repo> --family <family> [--slug <slug>] [--base-url <url>] [--emit-packet [path]] [--json] # L7: doctor a built _site/ with no Build Packet. Reads the local files only: --base-url is not fetched, it fills deploy.preview_url in the minimal packet (check served pages with qa run --site)
322
322
  campaigns-os bundle check --packet <campaign-runtime.build.json> [--require-qa] [--json] # validate the canonical migration/readback JSON bundle; never substitutes markdown
323
323
  campaigns-os sdk storage-check --target <git-root> --target-sdk <x.y.z> --manifest <SDK-manifest.json> --scope <dir,file> [--exclude <dir,file>] [--json]
324
+ campaigns-os sdk repin --target <static-campaign-repo> [--target-sdk <x.y.z>] [--apply] [--json] # rewrite the version segment of each semver-pinned Campaign Cart loader.js / campaign-cart.css URL (the references standardize's scan finds, @vX.Y.Z or @X.Y.Z) below the target, default the SDK support policy's preferred_minimum; @latest, @main, commit and prerelease refs, and pins at or above the target, are reported and left alone. Preview by default (path:line, old -> new URL, nothing written); --apply writes the files and a change record at .campaign-runtime/sdk-repin.json (campaigns-os-sdk-repin/v0: files touched, reference count, from/to versions) a Run Record can cite. A repo with _data/campaigns.json is refused (exit 2): page-kit sync owns that pin
324
325
  campaigns-os standardize --target <campaign-repo> [--family <family>] [--slug <slug>] [--sdk-support-policy <path.json>] [--field-contract <path.json>] [--no-doctor] [--json]
325
326
  campaigns-os theme inspect --packet <campaign-runtime.build.json> [--context <json>] [--theme-policy <inspect_only|auto|off>] [--json]
326
327
  campaigns-os theme generate --packet <campaign-runtime.build.json> [--context <json>] [--out-dir <dir>] [--force] [--json]
@@ -337,7 +338,7 @@ Usage:
337
338
  campaigns-os record build --packet <campaign-runtime.build.json> [--build-environment <development|production>] [--context <json>] [--report <json>] [--dry-run] [--json] # after page-kit build (--build-environment records stages.assembly.evidence.build_environment; local proof mode records development): stages.assembly completed with build_fingerprint = doctor's derived.build_output_fingerprint.value (and the Design Source Package material fingerprint when the report has one); stages.polish becomes required unless its evidence is bound to this exact output
338
339
  campaigns-os record polish --packet <campaign-runtime.build.json> --evidence <polish-evidence.json> [--context <json>] [--report <json>] [--dry-run] [--json] # after polish capture: stages.polish from the file's status (completed, completed_with_warnings, blocked with blockers, or skipped with skip_reason), evidence and optional repair_loop_defect, bound to doctor's current fingerprint; a completed status is refused, writing nothing, unless the polish gate doctor evaluates would pass. --dry-run runs every check and writes nothing
339
340
  campaigns-os record theme --packet <campaign-runtime.build.json> [--context <json>] [--report <json>] [--dry-run] [--json] # after the brand layer is linked and build is recorded: report.theme becomes applied with load_order after-next-core, css_path, commerce_pages and per-page evidence, only when each built commerce page that loads next-core.css loads brand-theme.css (or checkout-brand.css) after it and at least one does; a page loading neither is left out as the design's own markup; refused, writing nothing, otherwise. --dry-run runs every check and writes nothing
340
- campaigns-os record deploy --packet <campaign-runtime.build.json> --base-url <served url> [--context <json>] [--report <json>] [--dry-run] [--json] # a local preview (deploy.target local-serve) after polish is recorded: GETs every built page under the loopback URL (the campaign route root), then records deploy.preview_url on the packet and stages.deploy completed with the URL in outputs; refused, writing nothing, when the URL is not loopback or not the route root, a page does not answer 2xx, the build changed since it was recorded, or the theme gate is blocked. --dry-run runs every check, the requests included, and writes nothing
341
+ campaigns-os record deploy --packet <campaign-runtime.build.json> --base-url <served url> [--context <json>] [--report <json>] [--dry-run] [--json] # a local preview (deploy.target local-serve) after polish is recorded: GETs every built page under the loopback URL (the campaign route root), then records deploy.preview_url on the packet and stages.deploy completed with the URL in outputs and the build fingerprint it probed (a later record build of other output makes deploy required again); refused, writing nothing, when the URL is not loopback or not the route root, a page does not answer 2xx, the build changed since it was recorded, or the theme gate is blocked. --dry-run runs every check, the requests included, and writes nothing
341
342
  campaigns-os readback <target-repo-root> [--json] [--packet <path>] [--doctor <path>] [--context <path>] [--report <path>] [--qa-verdict <path>] [--findings <path>] # read-only projection of one run's emitted artifacts (packet, doctor output, build context, assembly report, QA verdict, findings export): artifact states, per-artifact freshness against the checkout's HEAD reflog, doctor warning grouping, skip cascades and cross-artifact divergences. Writes nothing, starts no process, touches no network, and records no lifecycle entry; --json emits one campaigns-os-readback/v2 object (docs/readback.md). Exit 2 for a missing target root or a Build Packet set freshness cannot single out.
342
343
  campaigns-os readback --example [--json] # project the bundled synthetic sample; freshness is not computable for it by design
343
344
  campaigns-os validate-assembly-report --report <json> [--json]
@@ -1052,7 +1053,25 @@ async function dispatch(command, args, { recorder = NOOP_RECORDER, ambient = nul
1052
1053
  }
1053
1054
 
1054
1055
  if (command === "sdk") {
1055
- if (args._[1] !== "storage-check" || args._.length !== 2) throw refused("Use: campaigns-os sdk storage-check --target <git-root> --target-sdk <x.y.z> --manifest <SDK-manifest.json> --scope <dir,file> [--exclude <dir,file>] [--json].");
1056
+ if (args._[1] === "repin" && args._.length === 2) {
1057
+ const known = new Set(["_", "target", "target-sdk", "apply", "json"]);
1058
+ for (const key of Object.keys(args)) if (!known.has(key)) throw refused(`Unknown SDK repin flag: --${key}`);
1059
+ for (const flag of ["apply", "json"]) {
1060
+ if (args[flag] !== undefined && args[flag] !== true) throw refused(`--${flag} is a boolean flag and takes no value.`);
1061
+ }
1062
+ const { runSdkRepin, formatSdkRepinReport, isRepinTargetVersion } = await import("./sdk-repin.mjs");
1063
+ const targetSdk = args["target-sdk"] === undefined ? null : requireArg(args, "target-sdk");
1064
+ if (targetSdk !== null && !isRepinTargetVersion(targetSdk)) throw refused(`--target-sdk ${targetSdk} is not a released SDK version (x.y.z).`);
1065
+ const result = runSdkRepin({
1066
+ targetRepo: requireArg(args, "target"),
1067
+ targetSdk,
1068
+ apply: args.apply === true,
1069
+ });
1070
+ console.log(args.json ? JSON.stringify(result, null, 2) : formatSdkRepinReport(result));
1071
+ process.exitCode = result.ok ? 0 : 2;
1072
+ return;
1073
+ }
1074
+ if (args._[1] !== "storage-check" || args._.length !== 2) throw refused("Use: campaigns-os sdk storage-check --target <git-root> --target-sdk <x.y.z> --manifest <SDK-manifest.json> --scope <dir,file> [--exclude <dir,file>] [--json], or campaigns-os sdk repin --target <static-campaign-repo> [--target-sdk <x.y.z>] [--apply] [--json].");
1056
1075
  const known = new Set(["_", "target", "target-sdk", "manifest", "scope", "exclude", "json"]);
1057
1076
  if (args.json !== undefined && args.json !== true) throw refused("--json is a boolean flag and takes no value.");
1058
1077
  for (const key of Object.keys(args)) if (!known.has(key)) throw refused(`Unknown SDK storage-check flag: --${key}`);
@@ -18,7 +18,13 @@
18
18
  // to an older writer that may still be alive between its mkdir and its owner
19
19
  // write (#501). A waiter refuses it after a short grace, leaving it for the
20
20
  // documented offline procedure. (See publishStagedDirectory for the one
21
- // mixed-version race this cannot close, tracked in #514.)
21
+ // mixed-version race this cannot close, tracked in #514.) Anything else at the
22
+ // lock path, a file or a directory holding other entries, is not a lock at
23
+ // all: it is refused by name and never removed (#514).
24
+ //
25
+ // Staging and released siblings a dead process left beside the lock are swept
26
+ // by the next holder, under the lock; a sibling whose owner is alive or
27
+ // unreadable is left alone (#514).
22
28
  //
23
29
  // The lock is reentrant for its holder: code running inside `fn` (in the
24
30
  // same async context) that asks for the same lock enters directly instead of
@@ -26,7 +32,7 @@
26
32
  // stage writers that take it too.
27
33
  import { AsyncLocalStorage } from "node:async_hooks";
28
34
  import { randomBytes } from "node:crypto";
29
- import { lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
35
+ import { lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
30
36
  import { basename, dirname, join, resolve } from "node:path";
31
37
 
32
38
  const heldLocks = new AsyncLocalStorage();
@@ -39,6 +45,47 @@ const exists = (path) => {
39
45
  try { lstatSync(path); return true; } catch { return false; }
40
46
  };
41
47
 
48
+ // True only when the owner record names a process that no longer exists. An
49
+ // unreadable record, or a pid this process may not signal, is not dead.
50
+ const ownerIsDead = (owner) => {
51
+ if (!(Number.isInteger(owner?.pid) && owner.pid > 0 && typeof owner.token === "string")) return false;
52
+ try { process.kill(owner.pid, 0); return false; } catch (error) { return error.code === "ESRCH"; }
53
+ };
54
+
55
+ const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
56
+
57
+ // The siblings this module creates beside a lock and normally removes itself:
58
+ // a crash leaves staging and recovery-staging ones, a release whose tomb could
59
+ // not be put back leaves a released one. Each carries an owner.json.
60
+ function siblingPattern(path) {
61
+ return new RegExp(`^${escapeRegExp(basename(path))}\\.(?:staging|recovery-staging|released)-[0-9a-f]{32}$`);
62
+ }
63
+
64
+ // Called only by the holder of the lock, so no two sweeps race, and a
65
+ // sibling is removed only when its owner is a dead process: a live owner may
66
+ // still be staging or releasing, and an unreadable one may be between its
67
+ // mkdir and its owner write. Best effort; a failure leaves the sibling.
68
+ function sweepDeadSiblings(path) {
69
+ const parent = dirname(path);
70
+ const pattern = siblingPattern(path);
71
+ let entries;
72
+ try { entries = readdirSync(parent); } catch { return; }
73
+ for (const entry of entries) {
74
+ if (!pattern.test(entry)) continue;
75
+ const sibling = join(parent, entry);
76
+ try {
77
+ const stat = lstatSync(sibling);
78
+ if (!stat.isDirectory() || stat.isSymbolicLink()) continue;
79
+ if (!ownerIsDead(readOwner(join(sibling, "owner.json")))) continue;
80
+ rmSync(sibling, { recursive: true, force: true });
81
+ } catch {}
82
+ }
83
+ }
84
+
85
+ // What a lock directory may hold besides its owner record: the recovery
86
+ // claim. A directory without owner.json that holds anything else is not a lock.
87
+ const LOCK_ENTRIES = new Set(["owner.json", ".recovery"]);
88
+
42
89
  // The reentrancy key names the lock directory through its real parent, so a
43
90
  // holder that reached the target through a symlink still recognizes itself.
44
91
  function lockKey(path) {
@@ -106,9 +153,7 @@ function createLock(path, { budgetMs, unavailable, now = Date.now, ownerlessGrac
106
153
  try {
107
154
  const stat = lstatSync(path);
108
155
  if (!stat.isDirectory() || stat.isSymbolicLink()) return false;
109
- const current = readOwner(ownerPath);
110
- if (!(Number.isInteger(current?.pid) && current.pid > 0 && typeof current.token === "string")) return false;
111
- try { process.kill(current.pid, 0); return false; } catch (error) { return error.code === "ESRCH"; }
156
+ return ownerIsDead(readOwner(ownerPath));
112
157
  } catch {
113
158
  return false;
114
159
  }
@@ -179,7 +224,32 @@ function createLock(path, { budgetMs, unavailable, now = Date.now, ownerlessGrac
179
224
  return current - start >= budgetMs;
180
225
  };
181
226
  const fail = (error) => unavailable(error);
182
- const contended = () => Object.assign(new Error(`Lock is held: ${path}`), { code: "EEXIST" });
227
+ // Names what occupies the lock path when it is not a lock, else null. A
228
+ // file there, or a directory with no owner record that holds entries a lock
229
+ // never has, will not clear by waiting and is never removed here.
230
+ const obstruction = () => {
231
+ let stat;
232
+ try { stat = lstatSync(path); } catch { return null; }
233
+ if (stat.isSymbolicLink()) return "a symbolic link, not a lock";
234
+ if (!stat.isDirectory()) return stat.isFile() ? "a regular file, not a lock" : "a special file, not a lock";
235
+ if (exists(ownerPath)) return null;
236
+ let foreign;
237
+ try { foreign = readdirSync(path).filter((entry) => !LOCK_ENTRIES.has(entry)).sort(); } catch { return null; }
238
+ if (foreign.length === 0) return null;
239
+ const shown = foreign.slice(0, 3).join(", ") + (foreign.length > 3 ? `, and ${foreign.length - 3} more` : "");
240
+ return `a directory that is not a lock (no owner.json; it holds ${shown})`;
241
+ };
242
+ // The refusal once waiting ends, telling a held lock, an ownerless lock and
243
+ // a non-lock obstruction apart.
244
+ const contended = () => {
245
+ const found = obstruction();
246
+ if (found) {
247
+ return Object.assign(new Error(`Lock path is occupied by ${found}: ${path}`), { code: "ENOTLOCK", obstruction: found });
248
+ }
249
+ if (ownerless()) return Object.assign(new Error(`Lock has no owner record: ${path}`), { code: "EEXIST" });
250
+ return Object.assign(new Error(`Lock is held: ${path}`), { code: "EEXIST" });
251
+ };
252
+ const sweep = () => sweepDeadSiblings(path);
183
253
 
184
254
  const release = () => {
185
255
  if (readOwner(ownerPath)?.token !== token) return;
@@ -193,7 +263,7 @@ function createLock(path, { budgetMs, unavailable, now = Date.now, ownerlessGrac
193
263
  }
194
264
  };
195
265
 
196
- return { token, stage, publish, recover, heldBySelfProcess, expired, fail, contended, release };
266
+ return { token, stage, publish, recover, heldBySelfProcess, expired, fail, contended, sweep, release };
197
267
  }
198
268
 
199
269
  function reentrantKey(path) {
@@ -232,6 +302,7 @@ export async function withDirectoryLock(path, fn, options) {
232
302
  await sleep(20);
233
303
  }
234
304
  try {
305
+ lock.sweep();
235
306
  return await runHolding(key, lock.token, fn);
236
307
  } finally {
237
308
  lock.release();
@@ -263,6 +334,7 @@ export function withDirectoryLockSync(path, fn, options) {
263
334
  (options.sleep ?? sleepSync)(20);
264
335
  }
265
336
  try {
337
+ lock.sweep();
266
338
  return runHolding(key, lock.token, fn);
267
339
  } finally {
268
340
  lock.release();
@@ -108,7 +108,7 @@ import { evaluatePageKitSdkVersion, PAGE_KIT_SDK_VERSION_SCOPE } from "../page-k
108
108
  // `npm run build:spec` (tsc -> campaign-spec/dist) so the package runs on the
109
109
  // node engine in package.json without type-stripping. build runs on `prepare`,
110
110
  // so a fresh install (including the git-ref consumer) always has dist.
111
- import { normalize as normalizeCampaignSpec, runRules, specOnlyRules } from "../../campaign-spec/dist/index.js";
111
+ import { isReleasedSdkVersion, normalize as normalizeCampaignSpec, runRules, specOnlyRules } from "../../campaign-spec/dist/index.js";
112
112
  import { cmd, asInvocation } from "../install-invocation.mjs";
113
113
  import { specHashesMatch, specMaterialHash } from "../spec-identity.mjs";
114
114
  import {
@@ -2522,7 +2522,7 @@ function recordSmokeQc({ subject, targetRepo, pages, environment, deployBase, wa
2522
2522
  }
2523
2523
 
2524
2524
  function recordSdkMarkupGate({ subject, pages, errors, warnings, ready, derived }) {
2525
- const gate = evaluateSdkMarkup({ subject, pages });
2525
+ const gate = evaluateSdkMarkup({ subject, pages, sdkVersion: campaignSdkPin(derived?.target_repo, subject?.public_route_slug) });
2526
2526
  if (Array.isArray(derived?.checkpoint_gates)) derived.checkpoint_gates.push(gate);
2527
2527
 
2528
2528
  if (gate.status === "not_applicable") {
@@ -2553,6 +2553,16 @@ function recordSdkMarkupGate({ subject, pages, errors, warnings, ready, derived
2553
2553
  return gate;
2554
2554
  }
2555
2555
 
2556
+ // The campaign's SDK pin from the target's campaigns.json entry, when it is an
2557
+ // exact released version; else null. The SDK markup check reads a page's own
2558
+ // loader pin first and falls back to this one.
2559
+ function campaignSdkPin(targetRepo, publicRouteSlug) {
2560
+ if (!targetRepo || !publicRouteSlug) return null;
2561
+ const load = loadPageKitCampaignEntry({ targetRepo, publicRouteSlug });
2562
+ const version = load.status === "ok" ? load.entry?.sdk_version : null;
2563
+ return isReleasedSdkVersion(version) ? version : null;
2564
+ }
2565
+
2556
2566
  // Route drift: a CampaignSpec page whose declared route has no built page at
2557
2567
  // that path. page-kit derives the public route from the source FILENAME, so a
2558
2568
  // file named presell-running.html builds at /presell-running/ even if the spec
@@ -5,6 +5,7 @@ import { withHtmlScanSnapshot } from "../html-scan.mjs";
5
5
  import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
6
6
  import { dirname, join, resolve } from "node:path";
7
7
  import { shellToken } from "../shell-token.mjs";
8
+ import { cmd } from "../install-invocation.mjs";
8
9
  import { commitAssemblyReport, recordProducerStageOutcome } from "../stage-ledger.mjs";
9
10
  import { annotateDoctorIssueCauses } from "../finding-cause.mjs";
10
11
  import { DOCTOR_SIDECAR_SCHEMA } from "../doctor-sidecar.mjs";
@@ -244,6 +245,18 @@ export function doctorBuiltOutput(args) {
244
245
  qc_results: [],
245
246
  };
246
247
  ready.push(`Resolved ${scope.html_count} built page(s) from ${relFromDir(targetRepo, scope.campaign_dir)} (slug "${scope.slug || "(site root)"}")`);
248
+ // #583: --base-url reads like a check of the deployed site, but these checks
249
+ // read only the local built files. Say so wherever the URL is given, and
250
+ // name the command that does load the served pages.
251
+ if (baseUrl) {
252
+ derived.base_url = { value: baseUrl, fetched: false, recorded_as: "deploy.preview_url" };
253
+ // Without --family there is no value to print that the CLI would accept,
254
+ // so the command is printed only when it is runnable as shown.
255
+ const servedCheck = family
256
+ ? `To check the served pages, run ${cmd("qa", `run --site ${shellToken(String(args.built || args.site))} --base-url ${shellToken(baseUrl)} --family ${shellToken(family)} --browser`)}.`
257
+ : `To check the served pages, run ${cmd("qa")} run --site with this --base-url and the campaign's --family, plus --browser.`;
258
+ ready.push(`Checked the local built files only: --base-url ${baseUrl} was not fetched; it only fills deploy.preview_url in the minimal Build Packet. ${servedCheck}`);
259
+ }
247
260
 
248
261
  const resolution = resolveBrandContractOnce(derived, family);
249
262
  let brandContract = null;
@@ -54,7 +54,7 @@ const COMMANDS = frozen({
54
54
  logout: { class: "auth" },
55
55
  demo: { class: "inline" },
56
56
  tooling: { subcommands: ["diagnose", "setup", "status"] },
57
- sdk: { subcommands: ["storage-check"] },
57
+ sdk: { subcommands: ["repin", "storage-check"] },
58
58
  readback: { class: "projection" },
59
59
  start: { sweepRoot: "target" },
60
60
  "prepare-build": { sweepRoot: "target" },
@@ -86,6 +86,8 @@ const SUBCOMMAND_OVERRIDES = frozen({
86
86
  "tooling diagnose": { class: "inline" },
87
87
  "tooling setup": { class: "inline" },
88
88
  "sdk storage-check": { class: "inspection" },
89
+ // Preview by default: only --apply writes, so only --apply journals.
90
+ "sdk repin": { journalExemptWhen: { given: "target", unlessBare: "apply" } },
89
91
  "theme waive": { dryRun: true },
90
92
  "checkpoint accept": { dryRun: true },
91
93
  "checkpoint waive": { dryRun: true },
@@ -96,7 +96,7 @@ export function projectProgressObservation({workspace,context,report,doctor,cont
96
96
  }
97
97
  function lock(dir,fn,{budgetMs=1500}={}) {
98
98
  const lockPath=join(dir,'.allocation-lock');
99
- return withDirectoryLock(lockPath,fn,{budgetMs,unavailable:()=>Object.assign(new Error('progress.lock_unavailable'),{lockPath})});
99
+ return withDirectoryLock(lockPath,fn,{budgetMs,unavailable:(error)=>Object.assign(new Error('progress.lock_unavailable'),{lockPath,code:error?.code,obstruction:error?.obstruction})});
100
100
  }
101
101
  export async function persistProgressObservation(observation,{dir,now=()=>new Date(),historyLimit=32}={}) {
102
102
  mkdirSync(dir,{recursive:true,mode:0o700});
@@ -164,7 +164,8 @@ export async function observeProgress(args,continuation,{qaResult=null,packageVe
164
164
  if(remit.state==='failed')warn('[campaigns-os] Progress delivery pending; the local observation is retained. Lifecycle result is unchanged.');
165
165
  return {...remit,snapshot_id:snapshot.snapshot_id,reused};
166
166
  } catch(error) {
167
- if(error?.message==='progress.lock_unavailable')warn(`[campaigns-os] Progress allocation lock occupied${error.lockPath?` at ${error.lockPath}`:''}; wait for the current writer. If it stays occupied (an abandoned, ownerless or interrupted lock), stop all campaigns-os writers for this target, then remove that lock directory (docs/progress-snapshots.md). Lifecycle result is unchanged.`);
167
+ if(error?.message==='progress.lock_unavailable'&&error.obstruction)warn(`[campaigns-os] Progress allocation lock path ${error.lockPath} is occupied by ${error.obstruction}; it is never removed automatically. Move it out of the way. Lifecycle result is unchanged.`);
168
+ else if(error?.message==='progress.lock_unavailable')warn(`[campaigns-os] Progress allocation lock occupied${error.lockPath?` at ${error.lockPath}`:''}; wait for the current writer. If it stays occupied (an abandoned, ownerless or interrupted lock), stop all campaigns-os writers for this target, then remove that lock directory (docs/progress-snapshots.md). Lifecycle result is unchanged.`);
168
169
  else warn('[campaigns-os] Progress observation unavailable; lifecycle result is unchanged.');
169
170
  return {state:'failed',reason:'capture_unavailable'};
170
171
  }