@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.
- package/CHANGELOG.md +61 -0
- package/README.md +1 -0
- package/compatibility.json +1 -1
- package/contracts/campaign-cart-checkout-field-contract.v0.json +10 -0
- package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
- package/contracts/commerce-surface-catalog.json +27 -27
- package/contracts/effects.v1.json +96 -37
- package/contracts/release-ledger.json +333 -0
- package/contracts/supported-surface.json +3 -3
- package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
- package/docs/build-packet.md +11 -4
- package/docs/campaign-standardization-report.md +32 -0
- package/docs/design-source-package.md +7 -3
- package/docs/effects.md +2 -1
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/progress-snapshots.md +3 -1
- package/docs/qa-and-test-orders.md +28 -3
- package/docs/runtime-readiness.md +1 -1
- package/docs/sdk-storage-compatibility.md +15 -0
- package/docs/skills-revision.md +10 -10
- package/docs/supported-surface.md +1 -0
- package/package.json +1 -1
- package/schemas/campaign-runtime-assembly-report.v0.schema.json +5 -1
- package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
- package/skills/campaign-readback-classification/SKILL.md +3 -3
- package/skills/campaign-run-evidence/SKILL.md +3 -3
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +3 -3
- package/skills/next-campaigns-os/SKILL.md +3 -3
- package/skills/next-campaigns-os-setup/SKILL.md +3 -3
- package/skills/next-campaigns-polish/SKILL.md +3 -3
- package/skills/next-campaigns-qa/SKILL.md +3 -3
- package/skills.json +10 -10
- package/src/campaign-ecosystem.mjs +153 -31
- package/src/cli.mjs +22 -3
- package/src/directory-lock.mjs +79 -7
- package/src/doctor/checks.mjs +12 -2
- package/src/doctor/inspect.mjs +13 -0
- package/src/invocation.mjs +3 -1
- package/src/progress-node.mjs +3 -2
- package/src/qa-browser.mjs +80 -9
- package/src/qa-node.mjs +33 -4
- package/src/sdk-attribute-index.mjs +111 -23
- package/src/sdk-markup.mjs +49 -10
- package/src/sdk-repin.mjs +282 -0
- package/src/stage-ledger.mjs +1 -1
- package/src/stage-record.mjs +37 -6
- package/src/standardization-report.mjs +26 -1
- 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
|
|
184
|
-
// version policy like a discovered
|
|
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
|
|
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:
|
|
319
|
-
url:
|
|
320
|
-
ref:
|
|
321
|
-
version:
|
|
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
|
-
|
|
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
|
|
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) => ({
|
|
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:
|
|
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]
|
|
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}`);
|
package/src/directory-lock.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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();
|
package/src/doctor/checks.mjs
CHANGED
|
@@ -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
|
package/src/doctor/inspect.mjs
CHANGED
|
@@ -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;
|
package/src/invocation.mjs
CHANGED
|
@@ -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 },
|
package/src/progress-node.mjs
CHANGED
|
@@ -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
|
|
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
|
}
|