@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.
- package/AGENTS.md +4 -2
- package/CHANGELOG.md +629 -0
- package/README.md +8 -6
- package/agents/claude/CLAUDE.md +5 -1
- package/campaign-spec/dist/types.d.ts +2 -0
- package/contracts/agent-relevant-change-policy.v1.json +5 -0
- package/contracts/effects.v1.json +118 -25
- package/contracts/migration-sidecar-bundle.v0.json +9 -0
- package/contracts/release-ledger.json +1424 -0
- package/contracts/supported-surface.json +12 -11
- package/docs/build-packet.md +120 -8
- package/docs/campaigns-os-build-flow.md +3 -2
- package/docs/design-source-package.md +89 -15
- package/docs/effects.md +83 -2
- package/docs/local-setup.md +51 -0
- package/docs/migration-sidecar-bundle.md +6 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/progress-snapshots.md +16 -6
- package/docs/qa-and-test-orders.md +157 -17
- package/docs/release-ledger-authoring-guide.md +6 -4
- package/docs/runtime-readiness.md +1 -1
- package/docs/skills-revision.md +10 -10
- package/package.json +3 -2
- package/schemas/campaign-runtime-assembly-report.v0.schema.json +6 -1
- package/schemas/campaign-runtime-build-packet.v0.schema.json +6 -1
- package/schemas/campaign-spec.v4.schema.json +4 -0
- package/schemas/campaigns-os-progress-snapshot.v0.schema.json +1 -0
- package/schemas/campaigns-os-qa-verdict-sidecar.v0.schema.json +1 -0
- package/schemas/campaigns-os-qa-verdict.v0.schema.json +1 -0
- package/schemas/campaigns-os-run-record.v0.schema.json +1 -0
- package/skills/campaign-lifecycle-orientation/SKILL.md +13 -8
- package/skills/campaign-readback-classification/SKILL.md +3 -3
- package/skills/campaign-run-evidence/SKILL.md +8 -6
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +4 -4
- package/skills/next-campaigns-os/SKILL.md +17 -4
- package/skills/next-campaigns-os/references/session-intake.md +4 -4
- package/skills/next-campaigns-os-setup/SKILL.md +3 -3
- package/skills/next-campaigns-polish/SKILL.md +3 -3
- package/skills/next-campaigns-qa/SKILL.md +10 -9
- package/skills.json +11 -11
- package/src/build-brief.mjs +6 -4
- package/src/built-script-syntax.mjs +480 -0
- package/src/campaigns-api-key.mjs +99 -0
- package/src/cli-helpers.mjs +118 -0
- package/src/cli.mjs +796 -6963
- package/src/design-source-package.mjs +1 -1
- package/src/design-source-publication.mjs +898 -0
- package/src/diagnostic.mjs +2 -1
- package/src/directory-lock.mjs +270 -0
- package/src/doctor/checks.mjs +4415 -0
- package/src/doctor/inspect.mjs +636 -0
- package/src/doctor/next-step.mjs +731 -0
- package/src/finding-cause.mjs +14 -10
- package/src/install-invocation.mjs +29 -0
- package/src/invocation.mjs +179 -0
- package/src/lifecycle.mjs +5 -4
- package/src/polish-node.mjs +5 -2
- package/src/private-template-source.mjs +1 -1
- package/src/progress-node.mjs +9 -37
- package/src/progress.mjs +5 -3
- package/src/proof-policy.mjs +1 -1
- package/src/qa-analytics-correctness.mjs +3 -0
- package/src/qa-binding-evidence.mjs +76 -11
- package/src/qa-browser.mjs +778 -77
- package/src/qa-build-scope.mjs +47 -0
- package/src/qa-node.mjs +276 -39
- package/src/qa-publish.mjs +4 -0
- package/src/qa-sidecar.mjs +2 -0
- package/src/qa-verdict-discovery.mjs +11 -0
- package/src/qa-verdict-publish.mjs +1 -0
- package/src/qa-verdict.mjs +8 -1
- package/src/readback.mjs +2 -1
- package/src/run-record-closeout.mjs +3 -4
- package/src/run-record.mjs +4 -0
- package/src/sidecar-bundle.mjs +21 -0
- package/src/source-html-intake.mjs +1 -1
- package/src/source-html-manifest.mjs +9 -2
- package/src/spec-source-identity.mjs +44 -0
- package/src/stage-ledger.mjs +32 -1
- package/src/target-lock.mjs +54 -0
- package/src/template-brand-contract.mjs +17 -1
- 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;
|
package/src/qa-verdict.mjs
CHANGED
|
@@ -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 (!
|
|
102
|
-
return
|
|
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) {
|
package/src/run-record.mjs
CHANGED
|
@@ -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,
|
package/src/sidecar-bundle.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
+
}
|
package/src/stage-ledger.mjs
CHANGED
|
@@ -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
|
-
&&
|
|
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 =
|
|
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
|
+
}
|