@nextcommerce/campaigns-os 1.41.2 → 1.43.2

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 (83) hide show
  1. package/AGENTS.md +4 -2
  2. package/CHANGELOG.md +629 -0
  3. package/README.md +8 -6
  4. package/agents/claude/CLAUDE.md +5 -1
  5. package/campaign-spec/dist/types.d.ts +2 -0
  6. package/contracts/agent-relevant-change-policy.v1.json +5 -0
  7. package/contracts/effects.v1.json +118 -25
  8. package/contracts/migration-sidecar-bundle.v0.json +9 -0
  9. package/contracts/release-ledger.json +1424 -0
  10. package/contracts/supported-surface.json +12 -11
  11. package/docs/build-packet.md +120 -8
  12. package/docs/campaigns-os-build-flow.md +3 -2
  13. package/docs/design-source-package.md +89 -15
  14. package/docs/effects.md +83 -2
  15. package/docs/local-setup.md +51 -0
  16. package/docs/migration-sidecar-bundle.md +6 -1
  17. package/docs/orientation-contract-reference.md +1 -1
  18. package/docs/progress-snapshots.md +16 -6
  19. package/docs/qa-and-test-orders.md +157 -17
  20. package/docs/release-ledger-authoring-guide.md +6 -4
  21. package/docs/runtime-readiness.md +1 -1
  22. package/docs/skills-revision.md +10 -10
  23. package/package.json +3 -2
  24. package/schemas/campaign-runtime-assembly-report.v0.schema.json +6 -1
  25. package/schemas/campaign-runtime-build-packet.v0.schema.json +6 -1
  26. package/schemas/campaign-spec.v4.schema.json +4 -0
  27. package/schemas/campaigns-os-progress-snapshot.v0.schema.json +1 -0
  28. package/schemas/campaigns-os-qa-verdict-sidecar.v0.schema.json +1 -0
  29. package/schemas/campaigns-os-qa-verdict.v0.schema.json +1 -0
  30. package/schemas/campaigns-os-run-record.v0.schema.json +1 -0
  31. package/skills/campaign-lifecycle-orientation/SKILL.md +13 -8
  32. package/skills/campaign-readback-classification/SKILL.md +3 -3
  33. package/skills/campaign-run-evidence/SKILL.md +8 -6
  34. package/skills/contribution-intake/SKILL.md +3 -3
  35. package/skills/next-campaigns-build/SKILL.md +4 -4
  36. package/skills/next-campaigns-os/SKILL.md +17 -4
  37. package/skills/next-campaigns-os/references/session-intake.md +4 -4
  38. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  39. package/skills/next-campaigns-polish/SKILL.md +3 -3
  40. package/skills/next-campaigns-qa/SKILL.md +10 -9
  41. package/skills.json +11 -11
  42. package/src/build-brief.mjs +6 -4
  43. package/src/built-script-syntax.mjs +480 -0
  44. package/src/campaigns-api-key.mjs +99 -0
  45. package/src/cli-helpers.mjs +118 -0
  46. package/src/cli.mjs +796 -6963
  47. package/src/design-source-package.mjs +1 -1
  48. package/src/design-source-publication.mjs +898 -0
  49. package/src/diagnostic.mjs +2 -1
  50. package/src/directory-lock.mjs +270 -0
  51. package/src/doctor/checks.mjs +4415 -0
  52. package/src/doctor/inspect.mjs +636 -0
  53. package/src/doctor/next-step.mjs +731 -0
  54. package/src/finding-cause.mjs +14 -10
  55. package/src/install-invocation.mjs +29 -0
  56. package/src/invocation.mjs +179 -0
  57. package/src/lifecycle.mjs +5 -4
  58. package/src/polish-node.mjs +5 -2
  59. package/src/private-template-source.mjs +1 -1
  60. package/src/progress-node.mjs +9 -37
  61. package/src/progress.mjs +5 -3
  62. package/src/proof-policy.mjs +1 -1
  63. package/src/qa-analytics-correctness.mjs +3 -0
  64. package/src/qa-binding-evidence.mjs +76 -11
  65. package/src/qa-browser.mjs +778 -77
  66. package/src/qa-build-scope.mjs +47 -0
  67. package/src/qa-node.mjs +276 -39
  68. package/src/qa-publish.mjs +4 -0
  69. package/src/qa-sidecar.mjs +2 -0
  70. package/src/qa-verdict-discovery.mjs +11 -0
  71. package/src/qa-verdict-publish.mjs +1 -0
  72. package/src/qa-verdict.mjs +8 -1
  73. package/src/readback.mjs +2 -1
  74. package/src/run-record-closeout.mjs +3 -4
  75. package/src/run-record.mjs +4 -0
  76. package/src/sidecar-bundle.mjs +21 -0
  77. package/src/source-html-intake.mjs +1 -1
  78. package/src/source-html-manifest.mjs +9 -2
  79. package/src/spec-source-identity.mjs +44 -0
  80. package/src/stage-ledger.mjs +32 -1
  81. package/src/target-lock.mjs +54 -0
  82. package/src/template-brand-contract.mjs +17 -1
  83. package/src/tooling-setup.mjs +160 -0
@@ -1,3 +1,4 @@
1
+ import { resolveCampaignIdentity, localQaIdentifier } from "./spec-source-identity.mjs";
1
2
  // QA verdict discovery: the one walk over the local verdicts a campaign has
2
3
  // written, with the projections each reader needs.
3
4
  //
@@ -28,11 +29,20 @@ function qaVerdictSlug(packet) {
28
29
  // the slug as identity. A verdict is this campaign's when its campaign_slug
29
30
  // is one of them.
30
31
  export function qaVerdictIdentifiers(packet) {
32
+ if (packet?.spec?.local_spec_id != null) {
33
+ const identity = resolveCampaignIdentity(packet.spec);
34
+ return identity?.kind === "local_spec" ? [localQaIdentifier(identity.id)] : [];
35
+ }
31
36
  return [...new Set([optionalString(packet?.spec?.map_id), qaVerdictSlug(packet)].filter(Boolean))];
32
37
  }
33
38
 
34
39
  export function qaVerdictIdentityMatch(verdict, packet) {
35
40
  const slug = optionalString(verdict?.campaign_slug);
41
+ if (packet?.spec?.local_spec_id != null || verdict?.local_spec_id != null) {
42
+ const identity = resolveCampaignIdentity(packet?.spec);
43
+ return identity?.kind === "local_spec" && verdict?.local_spec_id === identity.id
44
+ && slug === localQaIdentifier(identity.id);
45
+ }
36
46
  return Boolean(slug) && qaVerdictIdentifiers(packet).includes(slug);
37
47
  }
38
48
 
@@ -174,6 +184,7 @@ export function qaVerdictCandidateScore(candidate, packet) {
174
184
  const mapId = optionalString(packet?.spec?.map_id);
175
185
  const slug = qaVerdictSlug(packet);
176
186
  let score = 0;
187
+ if (packet?.spec?.local_spec_id && qaVerdictIdentityMatch(verdict, packet)) score += 100;
177
188
  if (mapId && verdict.campaign_slug === mapId) score += 100;
178
189
  if (slug && verdict.campaign_slug === slug) score += 80;
179
190
  if (verdict.schema_version === "1.0" || verdict.schema_version === "campaigns-os-qa-verdict/v0") score += 10;
@@ -42,6 +42,7 @@ export const QA_VERDICT_PUBLISH_STATES = Object.freeze(["skipped", "ok", "failed
42
42
  * must not claim a credential is travelling in clear.
43
43
  */
44
44
  export async function publishQaVerdict(verdict, proxyBase, { fetchImpl = globalThis.fetch, remitImpl = remit } = {}) {
45
+ if (verdict?.local_spec_id != null) return { ...skippedQaVerdictPublish(), reason: "local_spec" };
45
46
  let httpStatus = null;
46
47
  let response = null;
47
48
  let failure = null;
@@ -1,3 +1,4 @@
1
+ import { resolveCampaignIdentity, localQaIdentifier } from "./spec-source-identity.mjs";
1
2
  export const QA_SCHEMA_VERSION = "1.0";
2
3
 
3
4
  export const STATUS = Object.freeze({
@@ -95,6 +96,7 @@ function dispositionWithCommercial(assertions, commercial) {
95
96
  export function createVerdict({
96
97
  runId,
97
98
  mapId,
99
+ localSpecId = null,
98
100
  publicRouteSlug = null,
99
101
  campaignRefId = null,
100
102
  specVersion,
@@ -126,7 +128,8 @@ export function createVerdict({
126
128
  run_id: runId,
127
129
  // campaign_slug carries the Map ID for schema back-compat; the true public
128
130
  // route slug rides alongside so consumers stop conflating the two.
129
- campaign_slug: mapId,
131
+ campaign_slug: localSpecId ? localQaIdentifier(localSpecId) : mapId,
132
+ ...(localSpecId ? { local_spec_id: localSpecId } : {}),
130
133
  public_route_slug: optionalString(publicRouteSlug),
131
134
  campaign_ref_id: campaignRefId,
132
135
  spec_version: specVersion,
@@ -208,6 +211,10 @@ export function deriveExceptions(assertions = []) {
208
211
 
209
212
  export function validateVerdict(verdict) {
210
213
  const errors = [];
214
+ if (verdict?.local_spec_id != null && (!resolveCampaignIdentity({ local_spec_id: verdict.local_spec_id })
215
+ || verdict.campaign_slug !== localQaIdentifier(verdict.local_spec_id))) {
216
+ errors.push("Local-spec verdict requires a valid local_spec_id and its local QA identifier.");
217
+ }
211
218
  if (!verdict || typeof verdict !== "object" || Array.isArray(verdict)) {
212
219
  return ["verdict: must be an object"];
213
220
  }
package/src/readback.mjs CHANGED
@@ -1187,6 +1187,7 @@ function renderIdentity(views, lines) {
1187
1187
  const spec = packet.spec || {};
1188
1188
  const campaign = packet.campaign || {};
1189
1189
  if (spec.map_id) entries.push(["map_id", spec.map_id, "build packet"]);
1190
+ if (spec.local_spec_id) entries.push(["local_spec_id", spec.local_spec_id, "build packet"]);
1190
1191
  if (campaign.public_route_slug) {
1191
1192
  entries.push(["public_route_slug", campaign.public_route_slug, "build packet"]);
1192
1193
  }
@@ -1194,7 +1195,7 @@ function renderIdentity(views, lines) {
1194
1195
  if (assembly.template_family) entries.push(["template_family", assembly.template_family, "build packet"]);
1195
1196
  } else if (doctor) {
1196
1197
  const derived = doctor.derived || {};
1197
- for (const field of ["map_id", "public_route_slug", "template_family"]) {
1198
+ for (const field of ["map_id", "local_spec_id", "public_route_slug", "template_family"]) {
1198
1199
  if (derived[field]) entries.push([field, derived[field], "doctor output"]);
1199
1200
  }
1200
1201
  }
@@ -1,3 +1,4 @@
1
+ import { campaignIdentitiesMatch } from "./spec-source-identity.mjs";
1
2
  // Run Record closeout recognition.
2
3
  //
3
4
  // `next` at stage "done" used to demand a Run Record unconditionally, because
@@ -93,13 +94,11 @@ function qaVerdictDigests(record) {
93
94
  * satisfy closeout — and `run-record` never re-emits under its id.
94
95
  */
95
96
  export function identityMatches(record, packet) {
96
- const mapId = text(packet?.spec?.map_id);
97
97
  const slug = text(packet?.campaign?.public_route_slug);
98
98
  const identity = isObject(record?.identity) ? record.identity : {};
99
- const recordMapId = text(identity.map_id);
100
99
  const recordSlug = text(identity.campaign_slug);
101
- if (!mapId || !slug || !recordMapId || !recordSlug) return false;
102
- return recordMapId === mapId && recordSlug === slug;
100
+ if (!slug || !recordSlug) return false;
101
+ return campaignIdentitiesMatch(identity, packet?.spec) && recordSlug === slug;
103
102
  }
104
103
 
105
104
  function outcome(reason_code, detail, entry = null) {
@@ -1,3 +1,4 @@
1
+ import { localSpecIdentityFields, resolveCampaignIdentity } from "./spec-source-identity.mjs";
1
2
  // Run Telemetry — per-run Run Record capture for Campaigns OS.
2
3
  // See docs/workflow-findings-sidecar.md (Run Telemetry).
3
4
  //
@@ -155,6 +156,8 @@ export function validateRunRecord(record) {
155
156
  if (record.identity != null) {
156
157
  if (typeof record.identity !== "object" || Array.isArray(record.identity)) {
157
158
  add("record.identity", "identity must be an object when present.");
159
+ } else if (record.identity.local_spec_id != null && resolveCampaignIdentity(record.identity)?.kind !== "local_spec") {
160
+ add("record.identity.local_spec_id", "local_spec_id must be a canonical local ID with no saved Map identity.");
158
161
  }
159
162
  }
160
163
 
@@ -449,6 +452,7 @@ export function selectRunFindingIds(journal, runId) {
449
452
  function normalizeIdentity(identity = {}) {
450
453
  return {
451
454
  map_id: identity.map_id ?? null,
455
+ ...localSpecIdentityFields(identity),
452
456
  campaign_slug: identity.campaign_slug ?? null,
453
457
  template_family: identity.template_family ?? null,
454
458
  entry_point_shape: identity.entry_point_shape ?? null,
@@ -84,6 +84,27 @@ function identityValuesAgree(identityField, left, right) {
84
84
  }
85
85
 
86
86
  function compareIdentity(errors, records, identityField) {
87
+ const local = identityField.local_spec_alternative;
88
+ if (local && records.get("build_packet")?.value?.spec?.local_spec_id != null) {
89
+ compareIdentity(errors, records, local);
90
+ // A local identity can never borrow a Map identity from another artifact.
91
+ for (const [kind, path] of Object.entries(identityField.artifact_paths)) {
92
+ if (kind === "qa_verdict") continue; // campaign_slug is the local QA storage key.
93
+ if (valueAt(records.get(kind)?.value, path) != null) {
94
+ errors.push(artifactFinding("bundle.identity.map_id_mismatch", kind,
95
+ "Local-spec bundle carries a saved Map identity.", "Regenerate artifacts from the same local CampaignSpec."));
96
+ }
97
+ }
98
+ return;
99
+ }
100
+ if (local) {
101
+ for (const [kind, path] of Object.entries(local.artifact_paths)) {
102
+ if (valueAt(records.get(kind)?.value, path) != null) {
103
+ errors.push(artifactFinding("bundle.identity.local_spec_id_mismatch", kind,
104
+ "Saved-Map bundle carries local-spec evidence.", "Regenerate artifacts from the same saved Map."));
105
+ }
106
+ }
107
+ }
87
108
  const label = identityField.name;
88
109
  const present = [];
89
110
  for (const [kind, path] of Object.entries(identityField.artifact_paths)) {
@@ -106,7 +106,7 @@ function declaredScopeSkip(page, { skipEntry = null, buildScope = null, manifest
106
106
  id: `dec_page_scope_${page.id}`,
107
107
  stage: "prepare_build",
108
108
  decision_type: "deterministic_derivation",
109
- decision: `recorded CampaignSpec page "${page.id}" as template stock, declared out of source scope (${skipEntry ? "explicit source-html manifest skip entry" : 'CampaignSpec build_scope mode "partial"'}); the build stage materialises the page from ${familyLabel}'s stock page, and intake demands no design source for it`,
109
+ decision: `recorded CampaignSpec page "${page.id}" as template stock, declared out of source scope (${skipEntry ? "explicit source-html manifest skip entry" : 'CampaignSpec build_scope mode "partial"'}); keep the route unbuilt unless the operator opts in to materialising it from ${familyLabel}'s stock page; intake demands no design source for it`,
110
110
  confidence: "high",
111
111
  template_stock: true,
112
112
  template_family: family,
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { existsSync, readFileSync, statSync } from "node:fs";
2
3
  import { resolve } from "node:path";
3
4
 
@@ -95,10 +96,16 @@ export function readSourceHtmlManifestFile(sourceRoot, { manifestPath = null } =
95
96
  return { ...readManifestAt(resolvedPath), explicit: Boolean(explicit) };
96
97
  }
97
98
 
99
+ // One read: the manifest is parsed from, and hashed over, the same bytes. An
100
+ // edit that lands on disk after the read changes neither, so the sha256 a
101
+ // consumer records always describes what was parsed (#501).
98
102
  function readManifestAt(manifestPath) {
99
103
  let manifest;
104
+ let sha256;
100
105
  try {
101
- manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
106
+ const bytes = readFileSync(manifestPath);
107
+ sha256 = createHash("sha256").update(bytes).digest("hex");
108
+ manifest = JSON.parse(bytes.toString("utf8"));
102
109
  } catch (error) {
103
110
  return {
104
111
  manifest: null,
@@ -125,7 +132,7 @@ function readManifestAt(manifestPath) {
125
132
  const warnings = (validation.warnings || []).map(
126
133
  (entry) => `Source-html manifest at ${manifestPath}: [${entry.code}] ${entry.message}`,
127
134
  );
128
- return { manifest, path: manifestPath, warning: null, warnings, validation };
135
+ return { manifest, path: manifestPath, sha256, warning: null, warnings, validation };
129
136
  }
130
137
 
131
138
  function validateManifestPage(entry, index, add, addWarning = () => {}) {
@@ -0,0 +1,44 @@
1
+ // Stable campaign identity is separate from both the public route and the
2
+ // material spec hash. Local IDs never identify a saved Map or a portal URL.
3
+ export const LOCAL_SPEC_ID_PATTERN = "^[A-Za-z0-9_-]{1,64}$";
4
+ const localIdPattern = new RegExp(LOCAL_SPEC_ID_PATTERN);
5
+ const text = value => typeof value === "string" && value.trim() ? value.trim() : null;
6
+
7
+ export function campaignSpecIdentity(spec) {
8
+ return {
9
+ map_id: spec?.spec_identity?.map_id ?? spec?.map_id ?? null,
10
+ local_spec_id: spec?.spec_identity?.local_spec_id ?? null,
11
+ };
12
+ }
13
+
14
+ export function resolveCampaignIdentity(fields) {
15
+ // Saved Map IDs retain their existing whitespace normalization. Local IDs
16
+ // are canonical, repository-owned tokens: never trim one into another ID.
17
+ const mapId = text(fields?.map_id);
18
+ const localId = fields?.local_spec_id;
19
+ if (localId != null) {
20
+ if (fields?.map_id != null || typeof localId !== "string" || !localIdPattern.test(localId)) return null;
21
+ return { kind: "local_spec", id: localId };
22
+ }
23
+ return mapId ? { kind: "saved_map", id: mapId } : null;
24
+ }
25
+
26
+ export function campaignIdentitiesMatch(left, right) {
27
+ const a = resolveCampaignIdentity(left);
28
+ const b = resolveCampaignIdentity(right);
29
+ return !!a && !!b && a.kind === b.kind && a.id === b.id;
30
+ }
31
+
32
+ export function localSpecIdentityFields(fields) {
33
+ if (fields?.local_spec_id == null) return {};
34
+ const identity = resolveCampaignIdentity(fields);
35
+ // Omitting a malformed marker would let a conflicting identity fall back to
36
+ // its Map ID. Writers must refuse it rather than silently change its kind.
37
+ if (identity?.kind !== "local_spec") throw new Error("Invalid local_spec_id or conflicting saved Map identity.");
38
+ return { local_spec_id: identity.id };
39
+ }
40
+
41
+ export function localQaIdentifier(localSpecId) {
42
+ if (typeof localSpecId !== "string" || !localIdPattern.test(localSpecId)) throw new Error("Invalid local_spec_id.");
43
+ return `local-spec-${localSpecId}`;
44
+ }
@@ -1,7 +1,9 @@
1
+ import { campaignIdentitiesMatch } from "./spec-source-identity.mjs";
1
2
  import { existsSync, readFileSync } from "node:fs";
2
3
  import { markDoctorSidecarStale, writeDoctorSidecar, writeJsonAtomic } from "./doctor-sidecar.mjs";
3
4
  import { STATUS as QA_STATUS } from "./qa-verdict.mjs";
4
5
  import { isPlainObject, normalizeString as optionalString } from "./repo-scan.mjs";
6
+ import { withTargetLockSync } from "./target-lock.mjs";
5
7
  import {
6
8
  ASSEMBLY_REPORT_STAGE_KEYS,
7
9
  NEXT_STAGE_CONTRACTS,
@@ -377,7 +379,9 @@ export function qaGatePassedForCurrentBuild(report, gate, { buildFingerprint })
377
379
  */
378
380
  export function assemblyReportMatchesPacket(report, packet) {
379
381
  return isPlainObject(report)
380
- && optionalString(report?.identity?.map_id) === optionalString(packet?.spec?.map_id)
382
+ && (report?.identity?.local_spec_id != null || packet?.spec?.local_spec_id != null
383
+ ? campaignIdentitiesMatch(report?.identity, packet?.spec)
384
+ : optionalString(report?.identity?.map_id) === optionalString(packet?.spec?.map_id))
381
385
  && optionalString(report?.identity?.public_route_slug) === optionalString(packet?.campaign?.public_route_slug);
382
386
  }
383
387
 
@@ -422,6 +426,16 @@ export function assemblyReportMatchesPacket(report, packet) {
422
426
  * (waivers, evidence merges) pass no `stage`: they require the report to
423
427
  * exist and bind its identity themselves.
424
428
  *
429
+ * The read-modify-write runs under the per-target writer lock that
430
+ * prepare-build holds (src/target-lock.mjs, #501), so a stage producer's edit
431
+ * never lands between prepare-build's pre-publish evidence re-check and its
432
+ * publication; inside prepare-build's own critical section it enters
433
+ * directly. A workspace without `targetRepo` (only possible with an explicit
434
+ * refreshDoctor and no stale stamp) names no target to lock and runs as is.
435
+ * `lockBudgetMs` bounds the wait (default: the target lock budget). A caller
436
+ * whose mutate always returns null (a preview) passes `lock: false`: it
437
+ * writes nothing, so it takes no lock and creates no lock files.
438
+ *
425
439
  * Returns `{ written, skipped, report, reportPath, doctorOutPath }` where
426
440
  * `skipped` is `null`, `"absent"`, `"identity"` or `"unchanged"` and `report`
427
441
  * is what is now on disk (the mutated report when written, else the one read,
@@ -432,6 +446,10 @@ export function commitAssemblyReport(workspace, mutate, {
432
446
  staleReason = null,
433
447
  command = null,
434
448
  stage = null,
449
+ lockBudgetMs,
450
+ // lock: false skips the target lock entirely; only for callers that write
451
+ // nothing (the waiver dry-run preview). A real commit must take the lock.
452
+ lock = true,
435
453
  } = {}) {
436
454
  const hasRefresh = typeof refreshDoctor === "function";
437
455
  const hasStale = typeof staleReason === "string" && staleReason.trim();
@@ -450,6 +468,19 @@ export function commitAssemblyReport(workspace, mutate, {
450
468
  if (hasRefresh && !doctorOutPath) throw new TypeError("commitAssemblyReport requires a workspace with doctorOutPath to refresh the doctor sidecar.");
451
469
  if (hasStale && !targetRepo) throw new TypeError("commitAssemblyReport requires a workspace with targetRepo to stamp the doctor sidecar stale.");
452
470
 
471
+ const commit = () => commitAssemblyReportUnderLock(workspace, mutate, {
472
+ refreshDoctor, staleReason, command, stage, hasRefresh, reportPath, doctorOutPath, targetRepo,
473
+ });
474
+ if (!targetRepo || lock === false) return commit();
475
+ return withTargetLockSync(targetRepo, commit, {
476
+ command: command.trim(),
477
+ ...(lockBudgetMs === undefined ? {} : { budgetMs: lockBudgetMs }),
478
+ });
479
+ }
480
+
481
+ function commitAssemblyReportUnderLock(workspace, mutate, {
482
+ refreshDoctor, staleReason, command, stage, hasRefresh, reportPath, doctorOutPath, targetRepo,
483
+ }) {
453
484
  const outcome = { written: false, skipped: null, report: null, reportPath, doctorOutPath };
454
485
  const finish = () => {
455
486
  if (hasRefresh) {
@@ -0,0 +1,54 @@
1
+ // The per-target writer lock (#496, #501). prepare-build holds it from reading
2
+ // its inputs through publishing the packet, context and report; the stage
3
+ // writers (every commitAssemblyReport) hold it for their read-modify-write of
4
+ // the Assembly Report, so no stage evidence lands between prepare-build's
5
+ // pre-publish re-check and its rename. It lives beside the Design Source
6
+ // Package, inside the input directory prepare-build's writes already cover.
7
+ import { existsSync, mkdirSync } from "node:fs";
8
+ import { basename, dirname, join, resolve } from "node:path";
9
+ import { DESIGN_SOURCE_PACKAGE_REL_PATH } from "./design-source-package.mjs";
10
+ import { withDirectoryLock, withDirectoryLockSync } from "./directory-lock.mjs";
11
+
12
+ // Generous: a live holder is doing ordinary local work, and a holder that
13
+ // died is recovered by pid.
14
+ export const TARGET_LOCK_BUDGET_MS = 60000;
15
+
16
+ export function targetLockPath(targetRepo) {
17
+ const designSourcePackagePath = resolve(targetRepo, DESIGN_SOURCE_PACKAGE_REL_PATH);
18
+ return join(dirname(designSourcePackagePath), `.${basename(designSourcePackagePath)}.lock`);
19
+ }
20
+
21
+ // `command` names the waiting command. The lock does not record which command
22
+ // holds it, only a pid, so the holder is described generically. A lock with
23
+ // no owner record is called out on its own: it is never taken over, and the
24
+ // operator needs to know it will not clear by waiting.
25
+ function unavailable(targetRepo, lockPath, command) {
26
+ return (error) => {
27
+ if (error?.code !== "EEXIST") {
28
+ return new Error(`${command} could not take the target lock at ${lockPath}${error?.code ? ` (${error.code})` : ""}: ${error?.message}`, { cause: error });
29
+ }
30
+ if (existsSync(lockPath) && !existsSync(join(lockPath, "owner.json"))) {
31
+ return new Error(
32
+ `${command}: the target lock at ${lockPath} has no owner record, so it is never taken over automatically `
33
+ + "(an older campaigns-os release or an interrupted run left it). "
34
+ + `Confirm no campaigns-os process is working on ${targetRepo}, then remove that lock directory and retry.`,
35
+ );
36
+ }
37
+ return new Error(
38
+ `${command}: another campaigns-os command is writing ${targetRepo} (lock ${lockPath}). `
39
+ + "Retry after it finishes. If a run was interrupted, confirm no campaigns-os process is working on this target before removing that lock directory.",
40
+ );
41
+ };
42
+ }
43
+
44
+ export function withTargetLock(targetRepo, fn, { command = "campaigns-os", budgetMs = TARGET_LOCK_BUDGET_MS } = {}) {
45
+ const lockPath = targetLockPath(targetRepo);
46
+ mkdirSync(dirname(lockPath), { recursive: true });
47
+ return withDirectoryLock(lockPath, fn, { budgetMs, unavailable: unavailable(targetRepo, lockPath, command) });
48
+ }
49
+
50
+ export function withTargetLockSync(targetRepo, fn, { command = "campaigns-os", budgetMs = TARGET_LOCK_BUDGET_MS } = {}) {
51
+ const lockPath = targetLockPath(targetRepo);
52
+ mkdirSync(dirname(lockPath), { recursive: true });
53
+ return withDirectoryLockSync(lockPath, fn, { budgetMs, unavailable: unavailable(targetRepo, lockPath, command) });
54
+ }
@@ -327,6 +327,22 @@ export function paymentChromeAssetHashes(chrome, { label = "template brand contr
327
327
  return byBasename;
328
328
  }
329
329
 
330
+ // The starter templates' payment-logos.html partial renders one
331
+ // <img data-payment-logo="<method>"> per method and keeps it `hidden` until the
332
+ // campaign offers that method (server render, then payment-logos.js on
333
+ // next:initialized; payment_flags.show_<method>: true forces it visible). A
334
+ // hidden logo is the template gating the method, not residue, so both the
335
+ // static scan and browser QA drop those tags before matching. A visible one
336
+ // (forced on, or revealed at runtime) stays in and is judged like any chrome.
337
+ const PAYMENT_LOGO_IMG_TAG = /<img\b[^>]*\sdata-payment-logo\s*=[^>]*>/gi;
338
+ // Boolean attribute: present with any value (hidden, hidden="", hidden="true", …) means hidden.
339
+ const HIDDEN_ATTRIBUTE = /\shidden(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))?(?=[\s/>])/i;
340
+
341
+ export function withoutHiddenPaymentLogos(html) {
342
+ const text = typeof html === "string" ? html : "";
343
+ return text.replace(PAYMENT_LOGO_IMG_TAG, (tag) => (HIDDEN_ATTRIBUTE.test(tag) ? "" : tag));
344
+ }
345
+
330
346
  // Pure, static: the markers in rendered checkout HTML that say a payment method
331
347
  // shipped. Three sources, in order of authority: the SDK-owned
332
348
  // data-next-payment-method attribute every starter-template payment-methods
@@ -338,7 +354,7 @@ export function paymentChromeAssetHashes(chrome, { label = "template brand contr
338
354
  // browser QA, which fetches them to attribute the mark; a static scan cannot
339
355
  // tell a paypal strip from a card-only one by its filename.
340
356
  export function paymentMethodMarkupMatches(html, method, chrome = null) {
341
- const text = typeof html === "string" ? html : "";
357
+ const text = withoutHiddenPaymentLogos(html);
342
358
  const canonical = String(method || "").toLowerCase().replace(/[\s-]+/g, "_");
343
359
  if (!canonical) return [];
344
360
  const matches = [];
@@ -0,0 +1,160 @@
1
+ import { existsSync, lstatSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
2
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
3
+
4
+ const PACKAGE = "@nextcommerce/campaigns-os";
5
+ const CONTEXT = ".campaign-runtime/agent-context/CLAUDE.md";
6
+ const IMPORT = `@${CONTEXT}`;
7
+
8
+ // Setup composes the existing installers after npm has installed the project
9
+ // dependencies. It never chooses a campaign, scaffolds pages, or opens a run.
10
+ export function setupArguments(args, argv) {
11
+ const values = new Set(["target", "platform"]);
12
+ const flags = new Set(["dry-run", "json"]);
13
+ const seen = new Set();
14
+ const tokens = argv[0] === "campaigns-os" ? argv.slice(1) : argv;
15
+ for (let i = 2; i < tokens.length; i++) {
16
+ const token = tokens[i];
17
+ const key = token.startsWith("--") ? token.slice(2) : "";
18
+ if ((!values.has(key) && !flags.has(key)) || seen.has(key)) {
19
+ throw new Error(`tooling setup: unsupported or repeated argument ${JSON.stringify(token)}.`);
20
+ }
21
+ seen.add(key);
22
+ if (values.has(key)) {
23
+ if (!tokens[i + 1] || tokens[i + 1].startsWith("--")) throw new Error(`tooling setup: --${key} requires a value.`);
24
+ i++;
25
+ }
26
+ }
27
+ if (typeof args.target !== "string" || !args.target.trim()) throw new Error("tooling setup: select the campaign folder with --target <directory>.");
28
+ if (args.platform && args.platform !== "claude") throw new Error("tooling setup: this entry supports --platform claude. Other agents can use install-skills and install-agent-context.");
29
+ }
30
+
31
+ function json(path) {
32
+ return JSON.parse(readFileSync(path, "utf8"));
33
+ }
34
+
35
+ function hasContextImport(text) {
36
+ let fence = null;
37
+ let found = false;
38
+ for (const line of text.split(/\r?\n/)) {
39
+ const marker = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
40
+ if (fence) {
41
+ if (marker && marker[1][0] === fence[0] && marker[1].length >= fence.length && !marker[2].trim()) fence = null;
42
+ } else if (marker) {
43
+ fence = marker[1];
44
+ } else if (/^ {0,3}@/.test(line) && line.trim() === IMPORT) {
45
+ found = true;
46
+ }
47
+ }
48
+ if (!found && fence) throw new Error("tooling setup: close the unterminated code fence in CLAUDE.md before setup can append an active context import; no files were changed.");
49
+ return found;
50
+ }
51
+
52
+ function regularDestination(root, path) {
53
+ const rel = relative(root, path);
54
+ if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
55
+ throw new Error(`tooling setup: destination must stay inside the selected project: ${path}.`);
56
+ }
57
+ const parts = rel.split(sep);
58
+ let current = root;
59
+ for (let i = 0; i < parts.length; i++) {
60
+ current = join(current, parts[i]);
61
+ let stat;
62
+ try { stat = lstatSync(current); } catch (error) {
63
+ // Every existing ancestor has already been checked. Once a component is
64
+ // absent, its descendants cannot exist; dangling symlinks still have lstat.
65
+ if (error.code === "ENOENT") return;
66
+ throw error;
67
+ }
68
+ if (stat.isSymbolicLink() || (i < parts.length - 1 ? !stat.isDirectory() : !stat.isFile())) {
69
+ throw new Error(`tooling setup: preserve ${current}; expected a regular ${i < parts.length - 1 ? "directory" : "file"}, not a symlink or another file type.`);
70
+ }
71
+ }
72
+ }
73
+
74
+ export function setupTooling(args, { packageRoot, installSkills, installAgentContext, installBrowser }) {
75
+ const target = realpathSync(resolve(args.target));
76
+ const pkg = json(join(packageRoot, "package.json"));
77
+ const manifestPath = join(target, "package.json");
78
+ const lockPath = join(target, "package-lock.json");
79
+ if (!existsSync(manifestPath)) {
80
+ throw new Error(`tooling setup: package.json is missing. For a new project, follow the pinned install in ${join(packageRoot, "docs/local-setup.md")}. For an existing project, restore its manifest and lockfile and run npm ci.`);
81
+ }
82
+ if (!existsSync(lockPath)) {
83
+ throw new Error("tooling setup: package-lock.json is missing. Restore the project's reviewed lockfile, or generate it from its existing dependency pins with npm install, then rerun setup. Do not replace the project's page-kit pin with a new-project example.");
84
+ }
85
+ const manifest = json(manifestPath);
86
+ const pins = [manifest.devDependencies?.[PACKAGE], manifest.dependencies?.[PACKAGE]].filter(Boolean);
87
+ if (!pins.length || pins.some((pin) => pin !== pkg.version)) {
88
+ throw new Error(`tooling setup: the project must pin ${PACKAGE} exactly to the running version ${pkg.version}; preserve its current pin or explicitly install the reviewed version first.`);
89
+ }
90
+ const installed = join(target, "node_modules", "@nextcommerce", "campaigns-os");
91
+ if (!existsSync(installed) || realpathSync(installed) !== realpathSync(packageRoot)) {
92
+ throw new Error("tooling setup: run the selected project's installed copy: cd into that folder and use npx --no-install campaigns-os tooling setup --target . --platform claude.");
93
+ }
94
+ const lock = json(lockPath);
95
+ if (lock.packages?.["node_modules/@nextcommerce/campaigns-os"]?.version !== pkg.version) {
96
+ throw new Error("tooling setup: the lockfile does not match the project toolkit pin; reconcile the reviewed dependency with npm before setup.");
97
+ }
98
+ if (!manifest.dependencies?.["next-campaign-page-kit"] && !manifest.devDependencies?.["next-campaign-page-kit"]) {
99
+ throw new Error("tooling setup: install next-campaign-page-kit in this project first. No campaign pages have been scaffolded or changed.");
100
+ }
101
+ if (!existsSync(join(target, "node_modules", "next-campaign-page-kit", "package.json"))) {
102
+ throw new Error("tooling setup: page-kit is declared but not installed; run npm ci in the selected project first.");
103
+ }
104
+
105
+ // Preflight every destination before any installer runs. Custom repository
106
+ // instructions are preserved; only one Claude import line is appended.
107
+ const instructions = join(target, "CLAUDE.md");
108
+ regularDestination(target, instructions);
109
+ regularDestination(target, join(target, ".gitignore"));
110
+ for (const name of ["CLAUDE.md", "AGENTS.md", "campaigns-os.mdc", "copilot-instructions.md"]) {
111
+ const dest = join(target, ".campaign-runtime", "agent-context", name);
112
+ regularDestination(target, dest);
113
+ const source = { "CLAUDE.md": "agents/claude/CLAUDE.md", "AGENTS.md": "agents/codex/AGENTS.md", "campaigns-os.mdc": "agents/cursor/campaigns-os.mdc", "copilot-instructions.md": "agents/copilot/copilot-instructions.md" }[name];
114
+ if (existsSync(dest) && readFileSync(dest, "utf8") !== readFileSync(join(packageRoot, source), "utf8")) {
115
+ throw new Error(`tooling setup: ${dest} differs from this toolkit's context. Preserve and reconcile it before rerunning setup; no files were changed.`);
116
+ }
117
+ }
118
+ const prior = existsSync(instructions) ? readFileSync(instructions, "utf8") : "";
119
+ const hasImport = hasContextImport(prior);
120
+ // Do the fallible download before changing the shared skills or project.
121
+ const browser = args["dry-run"] ? { ok: true, status: "not_run" } : installBrowser({ json: Boolean(args.json) });
122
+ const skills = browser.ok ? installSkills(null, Boolean(args["dry-run"]), "claude") : null;
123
+ const context = browser.ok ? installAgentContext(target, Boolean(args["dry-run"])) : null;
124
+ const contextFailed = context?.gitignore?.action === "skipped";
125
+ const ready = browser.ok && !contextFailed;
126
+ if (ready && !args["dry-run"] && !hasImport) {
127
+ writeFileSync(instructions, `${prior}${prior && !prior.endsWith("\n") ? "\n" : ""}\n${IMPORT}\n`);
128
+ }
129
+ const revision = json(join(packageRoot, "skills.json")).bundle_revision;
130
+ return {
131
+ ok: ready,
132
+ status: !browser.ok ? "browser_install_failed" : contextFailed ? "context_install_failed" : args["dry-run"] ? "dry_run" : "restart_required",
133
+ target_repo: target,
134
+ skills_revision: revision,
135
+ skills,
136
+ context,
137
+ instructions: { path: instructions, action: !ready ? "not_run" : hasImport ? "unchanged" : "append_import" },
138
+ browser,
139
+ next_action: contextFailed
140
+ ? `Setup could not add the runtime ignore block (${context.gitignore.reason}). Fix .gitignore and rerun setup; skills and context files may already be installed.`
141
+ : args["dry-run"]
142
+ ? "Run tooling setup with the same target and without --dry-run to install the browser, skills and agent context."
143
+ : browser.ok
144
+ ? `Restart Claude Code in this folder, then use the next-campaigns-os skill with your CampaignSpec and source material. Confirm the loaded bundle with npx --no-install campaigns-os tooling status --platform claude --skills-revision ${revision}.`
145
+ : "Fix the browser installation error and rerun tooling setup; existing page source and repository instructions are preserved.",
146
+ note: "Setup prepares tools; it does not scaffold pages, create a spec, select a campaign, log in, or prove that an agent loaded the installed skills.",
147
+ };
148
+ }
149
+
150
+ export function setupTextLines(result) {
151
+ return [
152
+ `Status: ${result.status.toUpperCase()}`,
153
+ `Campaign folder: ${result.target_repo}`,
154
+ `Skills revision: ${result.skills_revision}`,
155
+ `Browser: ${result.browser.status}`,
156
+ ...(result.browser.note ? [result.browser.note] : []),
157
+ result.next_action,
158
+ result.note,
159
+ ];
160
+ }