@nextcommerce/campaigns-os 1.37.3 → 1.43.1
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 +114 -10
- package/CHANGELOG.md +708 -0
- package/README.md +44 -31
- package/agents/claude/CLAUDE.md +5 -1
- package/campaign-spec/dist/types.d.ts +2 -0
- package/contracts/agent-relevant-change-policy.v1.json +11 -1
- package/contracts/effects.v1.json +4887 -0
- package/contracts/migration-sidecar-bundle.v0.json +9 -0
- package/contracts/release-ledger.json +1541 -0
- package/contracts/supported-surface.json +33 -12
- package/docs/build-packet.md +83 -22
- package/docs/campaigns-os-build-flow.md +2 -2
- package/docs/demo-preview.md +1 -1
- package/docs/diagnostics.md +7 -4
- package/docs/effects.md +350 -0
- package/docs/gateway-login.md +113 -0
- package/docs/local-setup.md +51 -0
- package/docs/migration-sidecar-bundle.md +6 -1
- package/docs/orientation-contract-reference.md +4 -1
- package/docs/progress-snapshots.md +9 -3
- package/docs/qa-and-test-orders.md +29 -13
- package/docs/readback.md +523 -0
- package/docs/runtime-readiness.md +1 -1
- package/docs/sdk-storage-compatibility.md +1 -1
- package/docs/skills-revision.md +364 -0
- package/docs/supported-surface.md +11 -3
- package/docs/versioning.md +8 -4
- package/package.json +10 -4
- package/schemas/campaign-runtime-assembly-report.v0.schema.json +6 -1
- package/schemas/campaign-runtime-build-packet.v0.schema.json +11 -1
- package/schemas/campaign-spec.v4.schema.json +4 -0
- package/schemas/campaigns-os-effects.v1.schema.json +211 -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-readback.v2.schema.json +267 -0
- package/schemas/campaigns-os-run-record.v0.schema.json +1 -0
- package/skills/campaign-lifecycle-orientation/SKILL.md +179 -0
- package/skills/campaign-readback-classification/SKILL.md +230 -0
- package/skills/campaign-run-evidence/SKILL.md +142 -0
- package/skills/contribution-intake/SKILL.md +85 -0
- package/skills/next-campaigns-build/SKILL.md +33 -12
- package/skills/next-campaigns-os/SKILL.md +59 -22
- package/skills/next-campaigns-os/references/session-intake.md +4 -4
- package/skills/next-campaigns-os-setup/SKILL.md +35 -14
- package/skills/next-campaigns-polish/SKILL.md +43 -17
- package/skills/next-campaigns-qa/SKILL.md +53 -28
- package/skills.json +40 -7
- package/src/admin-transport.mjs +123 -0
- package/src/cli.mjs +1178 -270
- package/src/credential-store.mjs +183 -0
- package/src/deviation.mjs +3 -2
- package/src/diagnostic.mjs +4 -1
- package/src/finding-cause.mjs +14 -10
- package/src/gate-actions.mjs +2 -2
- package/src/install-mode.mjs +17 -9
- package/src/lifecycle.mjs +96 -0
- package/src/login.mjs +152 -0
- package/src/package-install-fixture.mjs +3 -2
- package/src/polish-node.mjs +5 -2
- package/src/progress-node.mjs +3 -2
- package/src/progress.mjs +5 -3
- package/src/qa-node.mjs +105 -36
- package/src/qa-publish.mjs +112 -2
- 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 +1937 -0
- package/src/remit.mjs +17 -3
- package/src/run-record-closeout.mjs +3 -4
- package/src/run-record.mjs +4 -0
- package/src/sidecar-bundle.mjs +21 -0
- package/src/spec-source-identity.mjs +44 -0
- package/src/stage-ledger.mjs +4 -1
- package/src/tooling-setup.mjs +160 -0
package/src/cli.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { campaignSpecIdentity, resolveCampaignIdentity, campaignIdentitiesMatch, localSpecIdentityFields } from "./spec-source-identity.mjs";
|
|
1
2
|
import { withHtmlScanSnapshot, readHtmlScanText, htmlScanDigest } from "./html-scan.mjs";
|
|
2
3
|
import { createHash, randomUUID } from "node:crypto";
|
|
3
4
|
import { createDemo, demoArguments } from "./demo.mjs";
|
|
@@ -28,7 +29,10 @@ import { observeProgress, PROGRESS_OBSERVATION } from "./progress-node.mjs";
|
|
|
28
29
|
import { describeSdkIgnoredMetaTags, isSdkIgnoredMetaTag } from "./sdk-meta-tags.mjs";
|
|
29
30
|
import { HIDDEN_EAGER_MEDIA_ACTIONS, requiredActionText, substitutePacket } from "./gate-actions.mjs";
|
|
30
31
|
import { ORDER_PATH_DEPTH_DRIFT_CODE, orderPathDepthDriftText, orderPathDepthReconcileAction, orderPathDepthsDisagree, parseOrderPathDepthFlag } from "./proof-policy.mjs";
|
|
31
|
-
import { specMaterialHash } from "./spec-identity.mjs";
|
|
32
|
+
import { specMaterialHash, specHashesMatch } from "./spec-identity.mjs";
|
|
33
|
+
// The same predicate stage-ledger.mjs judges a mutator's result with, imported
|
|
34
|
+
// rather than re-stated so the waive preview and the commit agree by identity.
|
|
35
|
+
import { isPlainObject } from "./repo-scan.mjs";
|
|
32
36
|
import { anyAssemblyReportStageBlocked, applyDerivedAssemblyReportSummary, commitAssemblyReport, QA_GATE_PLACEHOLDER_TEXT_RESIDUE, qaGatePassedForCurrentBuild, recordProducerStageOutcome } from "./stage-ledger.mjs";
|
|
33
37
|
import { SESSION_ENDING_DISPOSITIONS, summarizePlaceholderTextGate, summarizePurchaseProof } from "./qa-verdict.mjs";
|
|
34
38
|
import { assessRunRecordCloseout, identityMatches, latestMatchingRunRecord, reasonIsRemitRecovery } from "./run-record-closeout.mjs";
|
|
@@ -95,14 +99,19 @@ import { campaignSidecarPaths, resolveCampaignWorkspace, targetRepoFor } from ".
|
|
|
95
99
|
import { canonicalPath, sameFile } from "./fs-identity.mjs";
|
|
96
100
|
import { DEFAULT_PROXY_BASE, fetchSpecByMapId } from "./spec-fetch.mjs";
|
|
97
101
|
import { writeMapSdkPin } from "./map-pin-writeback.mjs";
|
|
98
|
-
import { discoverQaVerdicts, iterateQaVerdicts, qaVerdictCandidateScore, qaVerdictCandidateTime, qaVerdictPathHints } from "./qa-verdict-discovery.mjs";
|
|
99
|
-
import { assertSecureProxyBase, boundedResponseText, DEFAULT_RUNS_ENDPOINT, describeRemitBaseKind, isLoopbackHostname, REMIT_RESULTS, remitRunRecord } from "./remit.mjs";
|
|
102
|
+
import { qaVerdictIdentityMatch, discoverQaVerdicts, iterateQaVerdicts, qaVerdictCandidateScore, qaVerdictCandidateTime, qaVerdictPathHints } from "./qa-verdict-discovery.mjs";
|
|
103
|
+
import { assertFetchAvailable, assertSecureProxyBase, boundedResponseText, DEFAULT_RUNS_ENDPOINT, describeRemitBaseKind, isLoopbackHostname, REMIT_RESULTS, remitRunRecord } from "./remit.mjs";
|
|
100
104
|
import {
|
|
101
105
|
aggregateLifecycleForRun,
|
|
102
106
|
appendLifecycleEntry,
|
|
103
107
|
LIFECYCLE_JOURNAL_REL_PATH,
|
|
104
108
|
NOOP_RECORDER,
|
|
105
109
|
readLifecycleJournal,
|
|
110
|
+
REFUSED_INVOCATION,
|
|
111
|
+
refusalSeen,
|
|
112
|
+
refused,
|
|
113
|
+
refusing,
|
|
114
|
+
runWithRefusalScope,
|
|
106
115
|
withCommandLifecycle,
|
|
107
116
|
} from "./lifecycle.mjs";
|
|
108
117
|
import {
|
|
@@ -139,7 +148,7 @@ import {
|
|
|
139
148
|
formatStandardizationReportMarkdown,
|
|
140
149
|
} from "./standardization-report.mjs";
|
|
141
150
|
import { singleLineDetail, singleLineField } from "./text-safety.mjs";
|
|
142
|
-
import { derivePackagePin, invocationPrefixFor, localInstallStatus, resolveInvocation } from "./install-mode.mjs";
|
|
151
|
+
import { derivePackagePin, invocationPrefixFor, LOCAL_INVOCATION_PREFIX, localInstallStatus, resolveInvocation } from "./install-mode.mjs";
|
|
143
152
|
import {
|
|
144
153
|
campaignRouteRoot,
|
|
145
154
|
isAbsoluteHttpUrl,
|
|
@@ -292,7 +301,6 @@ import {
|
|
|
292
301
|
} from "./spec-derive.mjs";
|
|
293
302
|
import {
|
|
294
303
|
adminApiBaseForStore,
|
|
295
|
-
defaultStoreTokenEnvVar,
|
|
296
304
|
normalizeStoreSubdomain,
|
|
297
305
|
parseStoreTokenSource,
|
|
298
306
|
planStoreProfileDerive,
|
|
@@ -316,10 +324,11 @@ export { derivePackagePin, localInstallStatus };
|
|
|
316
324
|
|
|
317
325
|
// Every command this CLI PRODUCES for an operator or agent to copy is spelled
|
|
318
326
|
// once, here, for the install it runs from (see install-mode.mjs): bare
|
|
319
|
-
// `campaigns-os` from a checkout, `npx campaigns-os` from a
|
|
320
|
-
// that pins the toolkit, `npx --yes <spec>` from an npx cache.
|
|
321
|
-
// are never rewritten after the fact — a path, a quoted
|
|
322
|
-
// value that happens to contain the words is left exactly
|
|
327
|
+
// `campaigns-os` from a checkout, `npx --no-install campaigns-os` from a
|
|
328
|
+
// campaign folder that pins the toolkit, `npx --yes <spec>` from an npx cache.
|
|
329
|
+
// Result payloads are never rewritten after the fact — a path, a quoted
|
|
330
|
+
// argument or a data value that happens to contain the words is left exactly
|
|
331
|
+
// as it is.
|
|
323
332
|
function cmd(verb, rest = "") {
|
|
324
333
|
const prefix = invocationPrefixFor(ROOT);
|
|
325
334
|
return `${prefix} ${verb}${rest ? ` ${rest}` : ""}`;
|
|
@@ -467,18 +476,23 @@ Usage:
|
|
|
467
476
|
campaigns-os standardize --target <campaign-repo> [--family <family>] [--slug <slug>] [--sdk-support-policy <path.json>] [--field-contract <path.json>] [--no-doctor] [--json]
|
|
468
477
|
campaigns-os theme inspect --packet <campaign-runtime.build.json> [--context <json>] [--theme-policy <inspect_only|auto|off>] [--json]
|
|
469
478
|
campaigns-os theme generate --packet <campaign-runtime.build.json> [--context <json>] [--out-dir <dir>] [--force] [--json]
|
|
470
|
-
campaigns-os theme waive --packet <campaign-runtime.build.json> --reason "<why>" --waived-by "<named human>" [--expires-at <ISO>] [--report <json>] [--json] # record an explicit theme-gate waiver on the assembly report; placeholders such as "operator" are refused
|
|
471
|
-
campaigns-os checkpoint waive --packet <campaign-runtime.build.json> --gate <checkpoint-id> --reason "<why>" --waived-by "<named human>" [--expires-at <ISO>] [--review-condition "<trigger>"] [--report <json>] [--json] # one bound is required; registered gates: page_kit.store_profile, page_kit.sdk_version, polish.hidden_eager_media, built_output.upsell_selector_scope
|
|
479
|
+
campaigns-os theme waive --packet <campaign-runtime.build.json> --reason "<why>" --waived-by "<named human>" [--expires-at <ISO>] [--report <json>] [--dry-run] [--json] # record an explicit theme-gate waiver on the assembly report; placeholders such as "operator" are refused. --dry-run validates the same way and prints the waiver it would write, without touching the report
|
|
480
|
+
campaigns-os checkpoint waive --packet <campaign-runtime.build.json> --gate <checkpoint-id> --reason "<why>" --waived-by "<named human>" [--expires-at <ISO>] [--review-condition "<trigger>"] [--report <json>] [--dry-run] [--json] # one bound is required; registered gates: page_kit.store_profile, page_kit.sdk_version, polish.hidden_eager_media, built_output.upsell_selector_scope. --dry-run runs every check (named human, bounds, registered and waivable gate) and prints the waiver it would write, without touching the report
|
|
472
481
|
campaigns-os page-kit sync --packet <campaign-runtime.build.json> [--dry-run] [--json] # write the CampaignSpec's Store Profile fields (campaign.store_*) and SDK pin (global_config.sdk_version, runtime.sdk_version alias) into the target's _data/campaigns.json entry for the packet's route, printing a field-by-field diff; the recovery for a doctor blocked on page_kit.store_profile / page_kit.sdk_version after a fresh scaffold. Writes only those ten fields, only from usable spec values (a bad pin, a non-http URL, a non-tel: phone URI or the demo value itself is reported as not synced, status PARTIAL); exit 2 when the entry or the spec is missing, or the spec identifies another campaign.
|
|
473
|
-
campaigns-os spec derive --packet <campaign-runtime.build.json> [--dry-run] [--json] [--report <json>] [--from-store <subdomain> [--store-token-source env:<VAR>]] [--write-map] [--proxy-base <url>] # write the fields the target repo already states into the packet's local CampaignSpec (spec.local_path): the SDK pin from _data/campaigns.json[<route>].sdk_version (global_config.sdk_version, and the runtime.sdk_version alias when declared), each page's page_url from the page tree under src/<route>/ (filename or permalink), and the analytics ids the entry carries (gtm_id -> analytics.providers.gtm.containerId, fb_pixel_id -> analytics.providers.facebook.pixelId); prints a field-by-field before -> after diff and writes nothing else. Repo-derived fields only and no network by default; --from-store <subdomain> (the <store> of <store>.29next.store) also reads
|
|
482
|
+
campaigns-os spec derive --packet <campaign-runtime.build.json> [--dry-run] [--json] [--report <json>] [--from-store <subdomain> [--store-token-source env:<VAR>]] [--write-map] [--proxy-base <url>] # write the fields the target repo already states into the packet's local CampaignSpec (spec.local_path): the SDK pin from _data/campaigns.json[<route>].sdk_version (global_config.sdk_version, and the runtime.sdk_version alias when declared), each page's page_url from the page tree under src/<route>/ (filename or permalink), and the analytics ids the entry carries (gtm_id -> analytics.providers.gtm.containerId, fb_pixel_id -> analytics.providers.facebook.pixelId); prints a field-by-field before -> after diff and writes nothing else. Repo-derived fields only and no network by default; --from-store <subdomain> (the <store> of <store>.29next.store) also reads through campaigns-os login gateway credentials (--store-token-source env:<VAR> explicitly selects the warned break-glass Admin path; a token never goes on the command line) and writes the nine campaign.store_* Store Profile fields: store_name and store_url (primary domain) and store_phone/store_phone_tel from GET /store/, and store_terms/privacy/contact/returns/shipping as https://<primary domain>/<slug>/ from the one storefront page (GET /pages/) whose slug or title names each policy; an empty store field, no page or several never empties the spec's value. A field the repo or store cannot state (a scaffold's seeded pin, an unbound page, an empty or malformed id, an active page_kit.sdk_version waiver, an empty store field, an unbound policy page) is reported as not derived, status PARTIAL; exit 2 when the packet, the spec or the target entry is missing, the spec identifies another campaign, or the store cannot be read (credential missing, 401/403, no such store, unreachable). --write-map also records the derived pin into the saved Map's Build hints (Campaign Cart SDK version) through the proxy Worker (PUT /api/maps/<spec.map_id> under X-Campaign-Key, the packet's Campaigns API key, with the Map's spec_hash as the X-Spec-Hash precondition): written when the Map declares no pin or one behind the repo, unchanged when equal, refused (warning, exit 0) when the Map pin is ahead or cannot be ordered, failed (error, exit 2) when the key is missing or mismatched, the Map is gone, was saved in between, or the proxy refuses the body; the write is recorded on the Assembly Report evidence[] and in the result's map object. --proxy-base overrides the canonical proxy (https, or a loopback host over http); --dry-run reads the Map and reports would_write without a PUT.
|
|
474
483
|
campaigns-os page-kit parity --packet <campaign-runtime.build.json> [--report <json>] [--json] # local proof mode (deploy.target local-serve): render the current source in development and production through the target's page-kit into temp dirs, assert the served _site/ is the current development render and that production differs from it only in environment-gated output (same page set, same route slugs, same Campaign Cart pin and next-api-key); records stages.assembly.evidence.local_proof.production_parity, which doctor reads as local_proof.production_parity. Exit 2 on a non-gated difference.
|
|
475
484
|
campaigns-os polish capture --packet <campaign-runtime.build.json> --base-url <url> [--report <json>] [--headed] [--auth-cookie <cookie>] [--json]
|
|
485
|
+
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.
|
|
486
|
+
campaigns-os readback --example [--json] # project the bundled synthetic sample; freshness is not computable for it by design
|
|
476
487
|
campaigns-os validate-assembly-report --report <json> [--json]
|
|
477
488
|
campaigns-os install-skills [--platform <claude|codex|agents|all>] [--target <skills-dir>] [--dry-run] [--json]
|
|
478
|
-
campaigns-os tooling
|
|
489
|
+
campaigns-os tooling setup --target <campaign-directory> [--platform claude] [--dry-run] [--json] # after installing the pinned project dependencies, install skills, connect Claude's context and install the QA browser; preserve existing pages and instructions, then restart the agent
|
|
490
|
+
campaigns-os login [--store <subdomain>]
|
|
491
|
+
campaigns-os logout [--store <subdomain>]
|
|
492
|
+
campaigns-os tooling status [--platform <claude|codex|agents|all>] [--target <skills-dir>] [--skills-revision <bundle-revision|skill-id@version>] [--packet <campaign-runtime.build.json>] [--force] [--json] # install-mode, git, skill freshness, and local gateway login/store/expiry/reported version. --skills-revision checks the bundle revision the skill you loaded states on its first body line (or that one skill's <skill-id>@<version>) against the bundle THIS CLI ships: revision_check is match, mismatch or unchecked, and a mismatch prints the full status and exits 2 because skill text already in context cannot be refreshed by re-running — start a fresh session. The pin check reports one executable per project: the project pin first — the first exact spec for this package (x.y.z, =x.y.z or vx.y.z) on the walk up from the nearest package.json, devDependencies then dependencies in each, entering a workspace root and stopping there, never peerDependencies or optionalDependencies — then the Build Packet's campaigns_os_version (the project's campaign-runtime.build.json, or --packet <path>); the Pin: line names the key and manifest (or packet) each version came from; pin.status is match, stale_pin (the pin is not the running version), conflicting_pin (the two sources disagree) or unpinned (neither, or only a range; exit 0). stale_pin and conflicting_pin exit 2 with the file to change; --force (bare) overrides them, is reported as pin.forced and lands on the lifecycle journal entry. See docs/skills-revision.md
|
|
479
493
|
campaigns-os tooling diagnose [--packet <packet>] [--platform <claude|codex|agents|all>] [--json] # read-only redacted support summary
|
|
480
494
|
campaigns-os install-agent-context --target <page-kit-dir> [--dry-run]
|
|
481
|
-
campaigns-os next --packet <json> [--no-write] [--no-remit] [--proxy-base <url>] [--json]
|
|
495
|
+
campaigns-os next [${NEXT_STAGE_ORDER.join("|")}] --packet <json> [--no-write] [--no-remit] [--proxy-base <url>] [--json] # no stage self-decides; returns gates[] + next_actions[] alongside the prompt
|
|
482
496
|
campaigns-os next setup --packet <json> [--context <json>] [--report <json>] [--json]
|
|
483
497
|
campaigns-os next build --packet <json> [--context <json>] [--report <json>] [--json]
|
|
484
498
|
campaigns-os next polish --packet <json> --report <json> [--json]
|
|
@@ -487,21 +501,24 @@ Usage:
|
|
|
487
501
|
campaigns-os qa resolve --packet <json> [--base-url <url>] [--no-probe] [--probe-timeout-ms <ms>] [--json] # probes the derived entry URLs; a dead route set reports routes_unresolved, an unprobed one ready_unprobed
|
|
488
502
|
campaigns-os qa run --packet <json> [--base-url <url>] [--browser] [--test-order <mode>] [--select-package <ref[:qty],...>] [--apply-coupon <code>] [--no-post-verdict] [--no-remit] [--output-dir <dir>] [--json]
|
|
489
503
|
campaigns-os qa promote --packet <json> --verdict <full-verdict.json> [--json] # project one explicit qa-output verdict to the committed .campaign-runtime/qa-verdict.json sidecar
|
|
490
|
-
campaigns-os qa publish --packet <json> [--verdict <full-verdict.json>] [--republish] [--proxy-base <url>] [--json] # post an already-stored verdict (the sidecar's run, or --verdict) to the QA portal without a re-run or an order; refuses a stale spec_hash or an already-published verdict
|
|
504
|
+
campaigns-os qa publish --packet <json> [--verdict <full-verdict.json>] [--republish] [--proxy-base <url>] [--dry-run] [--json] # post an already-stored verdict (the sidecar's run, or --verdict) to the QA portal without a re-run or an order; refuses a stale spec_hash or an already-published verdict. --dry-run runs every one of those refusal checks and prints what would be posted (endpoint, verdict run id, payload bytes) without the POST
|
|
491
505
|
campaigns-os qa policy set --packet <json> [--allowed-domains-confirmed true|false] [--deploy-target <target>] [--preview-url <url>] [--production-url <url>] [--order-path-depth <off|common|full>] [--json] # --order-path-depth writes qa.proof_policy.order_path_depth and refreshes the assembly report's proof_policy mirror
|
|
492
506
|
campaigns-os findings add --stage <stage> --kind <kind> --summary <text> [--details <text>] [--packet <json>] [--journal <path>] [--run-id <id>] [...context flags]
|
|
493
507
|
campaigns-os findings harvest --packet <json> [--context <json>] [--report <json>] [--journal <path>] [--run-id <id>] [--write] [--json]
|
|
494
508
|
campaigns-os findings list [--packet <json>] [--journal <path>] [--json]
|
|
495
509
|
campaigns-os findings export [--summary | --json] [--packet <json>] [--journal <path>]
|
|
496
|
-
campaigns-os run-record --packet <json> [--context <json>] [--report <json>] [--qa-verdict <path>] [--run-id <id>] [--new-run] [--journal <path>] [--lifecycle-journal <path>] [--surfaces <a,b>] [--primary-surface <s>] [--surface-confidence <text>] [--agent-total-tokens <n>] [--agent-elapsed-ms <n>] [--proxy-base <url>] [--no-remit] [--no-write] [--list] [--json]
|
|
510
|
+
campaigns-os run-record --packet <json> [--context <json>] [--report <json>] [--qa-verdict <path>] [--run-id <id>] [--new-run] [--journal <path>] [--lifecycle-journal <path>] [--surfaces <a,b>] [--primary-surface <s>] [--surface-confidence <text>] [--agent-total-tokens <n>] [--agent-elapsed-ms <n>] [--proxy-base <url>] [--no-remit] [--no-write] [--dry-run] [--list] [--json]
|
|
497
511
|
run_id: --run-id > the active run session > the most recent Run Record for this packet's campaign (re-emitted in place; a remitted one is left as written) > freshly minted. --new-run always mints; --list prints the run ids on disk for this packet (id, created_at, remit state, path) and, like --no-write, writes and sends nothing.
|
|
512
|
+
--dry-run assembles the record and prints it (with --json: dry_run, would_write, would_remit), then writes no file and sends nothing — where --no-write skips the assembly's reads too. Combining them is allowed and still writes nothing. \`run end --dry-run\` hands the flag on to run-record and leaves the run session open, so the close can still be made for real afterwards.
|
|
498
513
|
|
|
499
|
-
|
|
514
|
+
Commands other than login, logout, demo, and tooling diagnose accept [--lifecycle-journal <path>] (or env CAMPAIGNS_OS_LIFECYCLE_LOG) to append a command-lifecycle entry (command, argv shape, exit status, timing) for the run; pair with --run-id so run-record can embed it.
|
|
515
|
+
--no-write suppresses that append for every command, however the journal was selected (flag, env, or the active run session); a refused invocation (unknown command, an unknown subcommand refused before its handler runs, or a flag the command refuses up front) and \`run status\` never append one at all.
|
|
516
|
+
A refused invocation writes no file of its own. One effect still precedes argument refusal: \`start\`, \`prepare-build\`, \`build\`, \`run start\` and \`run end\` close out a STALE run session at the root they are about to act on (Run Record assembled and remitted under the usual consent, session file cleared) before argv is refused — a declared effect of those commands. --no-write suppresses that closeout too, so a --no-write invocation leaves the target byte-identical.
|
|
500
517
|
campaigns-os telemetry status|on [--proxy-base <url>] [--json] # machine-level Run Telemetry consent (gates remit only; capture is always local). \`on\` records consent for ONE endpoint: the canonical NEXT endpoint by default, or the --proxy-base you name (a loopback or staging receiver); \`status\` reports the stored scope and checks it against the canonical endpoint or the --proxy-base you name
|
|
501
518
|
campaigns-os telemetry off [--json] # turn remit off for every endpoint (takes no --proxy-base)
|
|
502
519
|
campaigns-os telemetry list [--packet <json> | --admin-key-env <VAR>] [--since <ISO>] [--package <v>] [--surface <s>] [--trusted] [--limit <n>] [--proxy-base <url>] [--trust-proxy-base] [--json] # read stored Run Records: tenant scope via the packet's campaign key, or cross-tenant via the ops admin key (default env CAMPAIGN_OPS_ADMIN_KEY). --proxy-base must be https unless it is a loopback host (allowed over http, with a warning that the credential is in clear).
|
|
503
520
|
campaigns-os run start [--packet <json>] [--run-id <id>] [--lifecycle-journal <path>] [--force] [--json] # begin an ambient run session: one run_id + journal auto-shared by every command, no per-command flags; with --packet the session lives in the packet's target repo, whatever the cwd
|
|
504
|
-
campaigns-os run status [--json] # active session + incomplete stages + deviation count + exact next command
|
|
521
|
+
campaigns-os run status [--json] # active session + incomplete stages + deviation count + exact next command; read-only — it never sweeps and never journals
|
|
505
522
|
campaigns-os run end [--packet <json>] [--no-remit] [--no-write] [--proxy-base <url>] [--json] # assemble the aggregated Run Record for the session, then clear it (also closes out a stale session at cwd); --proxy-base is handed to run-record, so the session record remits to that receiver under the consent scoped to it
|
|
506
523
|
|
|
507
524
|
Gates: when theme inspect finds a generatable brand theme and the campaign ships commerce pages, \`next polish|deploy|qa\` and \`qa run\` BLOCK until the brand layer is applied after next-core.css or explicitly waived (\`theme waive\` / \`qa run --theme-waive "<reason>"\`).
|
|
@@ -535,9 +552,15 @@ Examples:
|
|
|
535
552
|
npm run campaigns-os -- standardize --target examples/target-page-kit --json
|
|
536
553
|
`;
|
|
537
554
|
|
|
555
|
+
// `refused()`, its `REFUSED_INVOCATION` tag, and the `refusalSeen()` /
|
|
556
|
+
// `runWithRefusalScope()` accessors live in lifecycle.mjs — see the contract
|
|
557
|
+
// there.
|
|
558
|
+
// Command modules raise refusals too (`qa`'s unknown subcommand) and cli.mjs
|
|
559
|
+
// imports them, so the factory has to sit below both.
|
|
560
|
+
|
|
538
561
|
// Top-level commands the CLI dispatches, used to offer a did-you-mean
|
|
539
562
|
// suggestion on a typo instead of a bare "Unknown command". Derived from the
|
|
540
|
-
// `command === "…"` literals in
|
|
563
|
+
// `command === "…"` literals in main() and dispatch() (memoized on first use) so
|
|
541
564
|
// the list cannot drift as dispatch branches are added or removed. The regex
|
|
542
565
|
// tolerates whitespace and either quote style so common reformats don't
|
|
543
566
|
// silently empty the list; a known-commands test guards against a refactor
|
|
@@ -546,7 +569,7 @@ let knownCommandsCache = null;
|
|
|
546
569
|
export function knownCommands() {
|
|
547
570
|
if (knownCommandsCache) return knownCommandsCache;
|
|
548
571
|
const found = new Set(["help"]);
|
|
549
|
-
for (const match of dispatch.toString().matchAll(/command\s*===\s*["']([^"']+)["']/g)) {
|
|
572
|
+
for (const match of (main.toString() + dispatch.toString()).matchAll(/command\s*===\s*["']([^"']+)["']/g)) {
|
|
550
573
|
found.add(match[1]);
|
|
551
574
|
}
|
|
552
575
|
knownCommandsCache = [...found];
|
|
@@ -593,7 +616,7 @@ function closestCommand(input) {
|
|
|
593
616
|
return bestDistance <= budget ? best : null;
|
|
594
617
|
}
|
|
595
618
|
|
|
596
|
-
export async function main(argv) {
|
|
619
|
+
export async function main(argv, { authentication } = {}) {
|
|
597
620
|
const args = parseArgs(argv);
|
|
598
621
|
// `npx --yes -p <spec> campaigns-os <command>` and `npx --yes <spec>
|
|
599
622
|
// campaigns-os <command>` both hand the bin its own name as the first
|
|
@@ -603,65 +626,100 @@ export async function main(argv) {
|
|
|
603
626
|
if (args._[0] === "campaigns-os") args._.shift();
|
|
604
627
|
const command = args._[0] || "help";
|
|
605
628
|
|
|
606
|
-
//
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
629
|
+
// Everything below — dispatch and the onFinish that reads the verdict — runs
|
|
630
|
+
// inside ONE refusal scope, so a refusal raised by this invocation is visible
|
|
631
|
+
// only to this invocation's persistence step. Two main() calls interleaved
|
|
632
|
+
// in-process (a test, an embedding host) no longer share the verdict.
|
|
633
|
+
return runWithRefusalScope(async () => {
|
|
634
|
+
// Authentication never recovers/remits run sessions or records argv in a
|
|
635
|
+
// lifecycle journal. Credentials belong only in the user credential store.
|
|
636
|
+
if (command === "login" || command === "logout") {
|
|
637
|
+
const { runAuthentication } = await import("./login.mjs");
|
|
638
|
+
return runAuthentication(argv[0] === "campaigns-os" ? argv.slice(1) : argv, authentication);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// An offline sample must not recover sessions or emit lifecycle evidence.
|
|
642
|
+
if (command === "demo") {
|
|
643
|
+
// Validate raw tokens here: parsing loses duplicate flags. The private
|
|
644
|
+
// dispatcher then rechecks the parsed shape and extracts the target.
|
|
645
|
+
demoArguments(args, argv);
|
|
646
|
+
await dispatch(command, args);
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
614
649
|
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
650
|
+
// Diagnostic export is an inspection, including when a run is active or
|
|
651
|
+
// stale. Bypass session sweeping, ambient resolution, and lifecycle capture
|
|
652
|
+
// so no closeout/remit or journal write can occur before the projection.
|
|
653
|
+
if (command === "tooling" && args._[1] === "diagnose") {
|
|
654
|
+
const result = toolingDiagnose(args);
|
|
655
|
+
console.log(args.json ? JSON.stringify(result, null, 2) : diagnosticTextLines(result).join("\n"));
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
623
658
|
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
659
|
+
// Project setup must not recover campaign sessions, read gateway bindings,
|
|
660
|
+
// or emit lifecycle/telemetry evidence before a campaign is selected.
|
|
661
|
+
if (command === "tooling" && args._[1] === "setup") {
|
|
662
|
+
const { setupArguments, setupTooling, setupTextLines } = await import("./tooling-setup.mjs");
|
|
663
|
+
setupArguments(args, argv);
|
|
664
|
+
const { installQaBrowser } = await import("./qa-node.mjs");
|
|
665
|
+
const result = setupTooling(args, { packageRoot: ROOT, installSkills, installAgentContext, installBrowser: installQaBrowser });
|
|
666
|
+
console.log(args.json ? JSON.stringify(result, null, 2) : setupTextLines(result).join("\n"));
|
|
667
|
+
if (!result.ok) process.exitCode = 2;
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// Ambient run session (Tier 3): when `run start` is active, every command
|
|
672
|
+
// shares its run_id WITHOUT --run-id. Explicit --run-id still wins. Resolved
|
|
673
|
+
// ONCE here and threaded through dispatch + persistence so the run_id a
|
|
674
|
+
// command is tagged with and the journal it writes to come from a single
|
|
675
|
+
// read (no TOCTOU skew if the session changes mid-run).
|
|
676
|
+
//
|
|
677
|
+
// Before that read, close out any STALE session at the root this command is
|
|
678
|
+
// about to open a new one in. findRunSession ignores stale sessions so a new
|
|
679
|
+
// run never inherits an old run_id — but an ignored session was also an
|
|
680
|
+
// abandoned one: nine of them were found lingering with no Run Record and
|
|
681
|
+
// nothing remitted. Closing out is best-effort and never blocks the command.
|
|
682
|
+
const storageInspection = command === "sdk" && args._[1] === "storage-check";
|
|
683
|
+
// `readback` bypasses session resolution entirely, sweep included. Its
|
|
684
|
+
// `--packet` is a readback OVERRIDE naming the Build Packet to project, not a
|
|
685
|
+
// Build Packet to act on, and ambientRunSession treats that flag as a session
|
|
686
|
+
// locator: it read the named file whole through readJson, so a 40 MB packet
|
|
687
|
+
// was loaded into memory past readback's own 32 MiB bound before readback
|
|
688
|
+
// ever saw it, and a valid override exited 1 whenever some active session was
|
|
689
|
+
// bound to a different packet. Neither belongs to a command declared
|
|
690
|
+
// read-only. The lifecycle wrapper below still runs; the read-only exemption
|
|
691
|
+
// lives in persistLifecycleIfRequested, which writes no entry for readback.
|
|
692
|
+
const readOnlyProjection = command === "readback";
|
|
693
|
+
const sweptStale = storageInspection || readOnlyProjection ? [] : await closeOutStaleRunSessions(command, args);
|
|
694
|
+
const ambient = readOnlyProjection ? null : ambientRunSession(args);
|
|
695
|
+
|
|
696
|
+
// Wrap every command in the lifecycle instrumentation (T6): it captures the
|
|
697
|
+
// command, its argv shape, exit status, and timing. Re-throws unchanged so
|
|
698
|
+
// the CLI exit code is unaffected. Persistence runs via onFinish so it fires
|
|
699
|
+
// on BOTH the success and error paths — a command that THROWS (the most
|
|
700
|
+
// valuable failure telemetry) is recorded too, not just clean exits.
|
|
701
|
+
// Persistence is OPT-IN — an explicit --lifecycle-journal /
|
|
702
|
+
// CAMPAIGNS_OS_LIFECYCLE_LOG, or an active run session. With none, behavior
|
|
703
|
+
// is identical to before.
|
|
704
|
+
//
|
|
705
|
+
// `sessionHolder` is per-invocation, NOT module state: when start/
|
|
706
|
+
// prepare-build auto-open a run session mid-command, they publish it here
|
|
707
|
+
// so onFinish persists this command's own lifecycle entry into the new
|
|
708
|
+
// session — without two interleaved invocations ever sharing a session.
|
|
709
|
+
const sessionHolder = { current: ambient, autoStarted: false, adopted: false, qaResult: null, sweptStale };
|
|
710
|
+
await withCommandLifecycle(
|
|
711
|
+
{
|
|
712
|
+
command,
|
|
713
|
+
argvShape: argvShape(args),
|
|
714
|
+
runId: optionalString(args["run-id"]) || ambient?.session?.run_id || null,
|
|
715
|
+
onFinish: async (lifecycle, thrown) => {
|
|
716
|
+
persistLifecycleIfRequested(args, command, lifecycle, sessionHolder, thrown);
|
|
717
|
+
await autoEndRunSessionAfterTerminalQa(args, command, sessionHolder, thrown);
|
|
718
|
+
},
|
|
661
719
|
},
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
);
|
|
720
|
+
(recorder) => dispatch(command, args, recorder, ambient, sessionHolder),
|
|
721
|
+
);
|
|
722
|
+
});
|
|
665
723
|
}
|
|
666
724
|
|
|
667
725
|
function ambientRunSession(args = {}) {
|
|
@@ -777,14 +835,72 @@ function resolveLifecycleJournal(args, { ambient = null, fallbackDir = null } =
|
|
|
777
835
|
return fallbackDir ? join(resolve(fallbackDir), LIFECYCLE_JOURNAL_REL_PATH) : null;
|
|
778
836
|
}
|
|
779
837
|
|
|
838
|
+
// The commands and subcommands that actually IMPLEMENT `--dry-run`. The flag
|
|
839
|
+
// reaches every handler through a permissive parseArgs, so it is silently
|
|
840
|
+
// accepted everywhere — and the lifecycle exemption below, scoped to the flag
|
|
841
|
+
// alone, therefore fired on commands that ignore it: `qa run --dry-run` placed
|
|
842
|
+
// orders while writing no journal entry, and (see runSessionEndArgs) carried
|
|
843
|
+
// the flag into its own auto-end, which assembled no Run Record and left the
|
|
844
|
+
// session open. Keyed by `command`, or `command <args._[1]>` where the flag
|
|
845
|
+
// belongs to one subcommand. A command outside this set given `--dry-run`
|
|
846
|
+
// behaves exactly as it did before: it journals if it otherwise would, and it
|
|
847
|
+
// is not refused — refusing unknown flags is separate work.
|
|
848
|
+
const DRY_RUN_COMMANDS = new Set([
|
|
849
|
+
"page-kit sync",
|
|
850
|
+
"spec derive",
|
|
851
|
+
"install-skills",
|
|
852
|
+
"install-agent-context",
|
|
853
|
+
"run-record",
|
|
854
|
+
"run end",
|
|
855
|
+
"qa publish",
|
|
856
|
+
"checkpoint waive",
|
|
857
|
+
"theme waive",
|
|
858
|
+
]);
|
|
859
|
+
|
|
860
|
+
function commandImplementsDryRun(command, args = {}) {
|
|
861
|
+
return DRY_RUN_COMMANDS.has(command) || DRY_RUN_COMMANDS.has(`${command} ${args._?.[1]}`);
|
|
862
|
+
}
|
|
863
|
+
|
|
780
864
|
// Append the command's lifecycle entry only when capture is active: an explicit
|
|
781
865
|
// flag/env, or an ambient run session. Never throws — a lifecycle write must
|
|
782
866
|
// not break a command (telemetry never blocks a build). `help` is a no-op
|
|
783
867
|
// command and is not worth recording.
|
|
784
|
-
function persistLifecycleIfRequested(args, command, lifecycle, sessionHolder) {
|
|
868
|
+
function persistLifecycleIfRequested(args, command, lifecycle, sessionHolder, thrown) {
|
|
785
869
|
if (command === "help" || (command === "sdk" && args._[1] === "storage-check")) return;
|
|
870
|
+
// Three rules about what NEVER reaches the journal, whichever way the journal
|
|
871
|
+
// was selected (--lifecycle-journal, CAMPAIGNS_OS_LIFECYCLE_LOG, or an
|
|
872
|
+
// ambient run session). The in-process lifecycle object is still built; only
|
|
873
|
+
// the persistence below — the journal append and the deviation entry that
|
|
874
|
+
// follows it — is skipped, so a suppressed command still exits as before.
|
|
875
|
+
// 1. --no-write writes nothing, the journal included (issue #459: `run
|
|
876
|
+
// status --no-write` under an ambient session still created
|
|
877
|
+
// .campaign-runtime/command-lifecycle.jsonl).
|
|
878
|
+
// 2. A refused INVOCATION records nothing — an unknown top-level command,
|
|
879
|
+
// an unknown subcommand (`tooling statuss`), or a flag the command
|
|
880
|
+
// refuses up front (`standardize --dryrun`). None of them reached a
|
|
881
|
+
// handler, so a typo must not materialize a journal under the target.
|
|
882
|
+
// The tag the refusal carries IS the mechanism, read two ways: on the
|
|
883
|
+
// thrown error, or via refusalSeen() when the refusal was caught and
|
|
884
|
+
// rendered instead of thrown. There is deliberately no command-list
|
|
885
|
+
// backstop here — knownCommands() is regex-harvested and documented as
|
|
886
|
+
// fragile, so a second reading of it would be a second command list that
|
|
887
|
+
// could disagree with dispatch.
|
|
888
|
+
// 3. `run status` is read-only: it never sweeps and never journals.
|
|
889
|
+
if (args["no-write"] === true) return;
|
|
890
|
+
if (refusalSeen() || thrown?.code === REFUSED_INVOCATION) return;
|
|
891
|
+
if (command === "run" && args._[1] === "status") return;
|
|
786
892
|
// An inspection must not append to a delivered campaign's active run either.
|
|
787
|
-
|
|
893
|
+
// (--no-write is handled above, so only the read-only `doctor` form is left.)
|
|
894
|
+
if (command === "doctor" && args.packet && args.write !== true) return;
|
|
895
|
+
// The same rule, per-flag, for every command that takes `--dry-run`: the
|
|
896
|
+
// flag's whole promise is that the invocation writes nothing under the
|
|
897
|
+
// target, and the journal lives under the target. Only for the commands that
|
|
898
|
+
// make that promise, though — DRY_RUN_COMMANDS above.
|
|
899
|
+
if (args["dry-run"] === true && commandImplementsDryRun(command, args)) return;
|
|
900
|
+
// `readback` is declared read-only for the whole command, not per-flag: it
|
|
901
|
+
// writes nothing under the target, so a journal entry would be the one write
|
|
902
|
+
// its own contract forbids. Skipped the way doctor inspection is skipped.
|
|
903
|
+
if (command === "readback") return;
|
|
788
904
|
const ambient = sessionHolder?.current || null;
|
|
789
905
|
// A session auto-started DURING this command (start/prepare-build) is
|
|
790
906
|
// published into sessionHolder by autoStartRunSession; this command's own
|
|
@@ -857,31 +973,38 @@ export function recordQaStageOutcome(args, result) {
|
|
|
857
973
|
if (!existsSync(reportPath)) return false;
|
|
858
974
|
|
|
859
975
|
const verdict = result.verdict;
|
|
976
|
+
const hasLocalIdentity = packet.spec?.local_spec_id != null || verdict?.local_spec_id != null;
|
|
977
|
+
if (hasLocalIdentity && !qaVerdictIdentityMatch(verdict, packet)) return false;
|
|
860
978
|
const failed = (Array.isArray(verdict.assertions) ? verdict.assertions : [])
|
|
861
979
|
.filter((assertion) => assertion?.status === "fail")
|
|
862
980
|
.map((assertion) => `${assertion.id}: ${assertion.actual || "assertion failed"}`);
|
|
863
|
-
const committed = commitAssemblyReport(workspace, (report) =>
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
: [],
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
981
|
+
const committed = commitAssemblyReport(workspace, (report) => {
|
|
982
|
+
if (hasLocalIdentity && !specHashesMatch(verdict.spec_hash, report.identity?.spec_material_hash)) {
|
|
983
|
+
throw new Error("Local-spec QA verdict belongs to a different material revision; report evidence was not changed.");
|
|
984
|
+
}
|
|
985
|
+
return recordProducerStageOutcome(report, {
|
|
986
|
+
stage: "qa",
|
|
987
|
+
disposition: verdict.disposition,
|
|
988
|
+
timestamp: verdict.completed_at,
|
|
989
|
+
command: `campaigns-os ${QA_RUN_PRODUCER}`,
|
|
990
|
+
outputs: [result.local_path, result.qa_sidecar?.path].filter(isNonEmptyString),
|
|
991
|
+
blockers: verdict.disposition === "blocked" ? failed : [],
|
|
992
|
+
warnings: verdict.disposition === "ready_with_exceptions"
|
|
993
|
+
? ["QA completed with explicitly attributed exceptions; inspect the verdict artifact."]
|
|
994
|
+
: [],
|
|
995
|
+
// The producer knows its own run id and must restate it, or the stage
|
|
996
|
+
// keeps a previous run's identity beside this run's status and outputs.
|
|
997
|
+
identity: { verdict_run_id: optionalString(verdict.run_id) },
|
|
998
|
+
// Which build this verdict judged, and the gates whose browser outcome
|
|
999
|
+
// the doctor's static scan defers to (qaGatePassedForCurrentBuild). A
|
|
1000
|
+
// gate that never ran is left out, so silence never reads as a pass.
|
|
1001
|
+
evidence: qaStageGateEvidence(verdict, report),
|
|
1002
|
+
// Counts-only: never order ids, refs, emails or URLs (see
|
|
1003
|
+
// summarizePurchaseProof). This is what lets `next` tell a real purchase
|
|
1004
|
+
// path from a `--test-order off` diagnostic.
|
|
1005
|
+
proof: summarizePurchaseProof({ verdict, proofPolicy: packet.qa?.proof_policy }),
|
|
1006
|
+
});
|
|
1007
|
+
}, {
|
|
885
1008
|
stage: "qa",
|
|
886
1009
|
// The sidecar names this refresh as its producer (generated_by, #312).
|
|
887
1010
|
// This function is the `qa run` stage record, whichever token dispatch
|
|
@@ -1009,9 +1132,18 @@ async function autoEndRunSessionAfterTerminalQa(args, command, sessionHolder, th
|
|
|
1009
1132
|
return;
|
|
1010
1133
|
}
|
|
1011
1134
|
|
|
1135
|
+
// `dry-run` is inheritable because `run end --dry-run` hands it to
|
|
1136
|
+
// run-record on purpose. The invoking command here is `qa run`, which does
|
|
1137
|
+
// not implement the flag, so inheriting it would turn its own auto-end into
|
|
1138
|
+
// a dry run: no Run Record written, and the session left open after a
|
|
1139
|
+
// terminal QA. Every other inheritable flag `qa run` may carry is one
|
|
1140
|
+
// run-record reads the same way whoever passed it.
|
|
1141
|
+
const extraArgs = { ...args, "qa-verdict": result.local_path };
|
|
1142
|
+
if (!commandImplementsDryRun(command, args)) delete extraArgs["dry-run"];
|
|
1143
|
+
|
|
1012
1144
|
const summary = await closeRunSession(updatedFound, {
|
|
1013
1145
|
packet,
|
|
1014
|
-
extraArgs
|
|
1146
|
+
extraArgs,
|
|
1015
1147
|
silent: true,
|
|
1016
1148
|
promptForConsent: false,
|
|
1017
1149
|
onError: (error) => process.stderr.write(`[campaigns-os] run session auto-end skipped after QA: ${error.message}\n`),
|
|
@@ -1056,6 +1188,23 @@ async function dispatch(command, args, recorder = NOOP_RECORDER, ambient = null,
|
|
|
1056
1188
|
// them from this function's source.
|
|
1057
1189
|
const mode = PREPARE_MODES[command];
|
|
1058
1190
|
if (!mode) throw new Error(`No intake mode registered for "${command}"; add it to PREPARE_MODES.`);
|
|
1191
|
+
// Validate argv before --spec is inspected or --map-id fetches and caches.
|
|
1192
|
+
// Preserve resolveSpecPath's missing-input and map-id/target diagnostics.
|
|
1193
|
+
for (const flag of ["spec", "map-id", "source", "target", "source-kind", "proxy-base"]) {
|
|
1194
|
+
if (Object.hasOwn(args, flag)) requireArg(args, flag);
|
|
1195
|
+
}
|
|
1196
|
+
if (args.spec || (args["map-id"] && args.target)) requireArg(args, "source");
|
|
1197
|
+
if (args.spec) requireArg(args, "target");
|
|
1198
|
+
const sourceKind = optionalString(args["source-kind"], "html_funnel");
|
|
1199
|
+
if (sourceKind !== "html_funnel") {
|
|
1200
|
+
throw refused(`Unsupported source adapter "${sourceKind}". Use html_funnel for the current prepared-HTML flow.`);
|
|
1201
|
+
}
|
|
1202
|
+
if (Object.hasOwn(args, "wrapper-policy") && !isNonEmptyString(args["wrapper-policy"])) {
|
|
1203
|
+
throw refused(`--wrapper-policy needs a value. Accepted values: ${ADAPTER_WRAPPER_POLICIES.join(", ")}.`);
|
|
1204
|
+
}
|
|
1205
|
+
const wrapperPolicyFlag = refusing(() => parseWrapperPolicyFlag(args));
|
|
1206
|
+
refusing(() => requireDesignManifestValue(args));
|
|
1207
|
+
const orderPathDepthFlag = refusing(() => parseOrderPathDepthFlag(args, { command: "prepare-build" }));
|
|
1059
1208
|
// Tier 2: mark sub-phases so the lifecycle journal entry carries per-phase
|
|
1060
1209
|
// timings (spec resolve vs the prepare+doctor+install build), which Tier 1
|
|
1061
1210
|
// aggregates into `start:resolve-spec` / `start:prepare-build` stages.
|
|
@@ -1067,7 +1216,7 @@ async function dispatch(command, args, recorder = NOOP_RECORDER, ambient = null,
|
|
|
1067
1216
|
args.spec = resolved.specPath;
|
|
1068
1217
|
// `command` rides along for the doctor sidecar's generated_by stamp when
|
|
1069
1218
|
// the mode runs doctor (#312): threaded from here, not re-read from argv.
|
|
1070
|
-
const result = await recorder.time("prepare-build", () => prepareBuild(args, { ...mode, command, specInput }));
|
|
1219
|
+
const result = await recorder.time("prepare-build", () => prepareBuild(args, { ...mode, command, specInput, sourceKind, wrapperPolicyFlag, orderPathDepthFlag }));
|
|
1071
1220
|
result.spec_source = resolved;
|
|
1072
1221
|
autoStartRunSession(result, args, ambient, sessionHolder);
|
|
1073
1222
|
printPrepareResult(result, args);
|
|
@@ -1083,7 +1232,7 @@ async function dispatch(command, args, recorder = NOOP_RECORDER, ambient = null,
|
|
|
1083
1232
|
|
|
1084
1233
|
if (command === "bundle") {
|
|
1085
1234
|
const subcommand = args._[1] || "check";
|
|
1086
|
-
if (subcommand !== "check") throw
|
|
1235
|
+
if (subcommand !== "check") throw refused('Unknown bundle subcommand. Use: campaigns-os bundle check --packet <campaign-runtime.build.json> [--require-qa] [--json].');
|
|
1087
1236
|
const { inspectSidecarBundle, sidecarBundleReadinessLine } = await import("./sidecar-bundle.mjs");
|
|
1088
1237
|
const result = inspectSidecarBundle({
|
|
1089
1238
|
packetPath: requireArg(args, "packet"),
|
|
@@ -1097,10 +1246,10 @@ async function dispatch(command, args, recorder = NOOP_RECORDER, ambient = null,
|
|
|
1097
1246
|
}
|
|
1098
1247
|
|
|
1099
1248
|
if (command === "sdk") {
|
|
1100
|
-
if (args._[1] !== "storage-check" || args._.length !== 2) throw
|
|
1249
|
+
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].");
|
|
1101
1250
|
const known = new Set(["_", "target", "target-sdk", "manifest", "scope", "exclude", "json"]);
|
|
1102
|
-
if (args.json !== undefined && args.json !== true) throw
|
|
1103
|
-
for (const key of Object.keys(args)) if (!known.has(key)) throw
|
|
1251
|
+
if (args.json !== undefined && args.json !== true) throw refused("--json is a boolean flag and takes no value.");
|
|
1252
|
+
for (const key of Object.keys(args)) if (!known.has(key)) throw refused(`Unknown SDK storage-check flag: --${key}`);
|
|
1104
1253
|
const { scanSdkStorageCompatibility, formatStorageCompatibilityReport } = await import("./sdk-storage-compatibility.mjs");
|
|
1105
1254
|
const result = scanSdkStorageCompatibility({
|
|
1106
1255
|
cwd: requireArg(args, "target"),
|
|
@@ -1145,6 +1294,21 @@ async function dispatch(command, args, recorder = NOOP_RECORDER, ambient = null,
|
|
|
1145
1294
|
return;
|
|
1146
1295
|
}
|
|
1147
1296
|
|
|
1297
|
+
if (command === "readback") {
|
|
1298
|
+
// Read-only projection over a target's already-emitted artifacts. It owns
|
|
1299
|
+
// its own exit codes (0 for any projection it could form, 2 for a request
|
|
1300
|
+
// that cannot form one) rather than throwing, so a usage error reads as a
|
|
1301
|
+
// one-line refusal instead of a stack-shaped CLI error.
|
|
1302
|
+
const { runReadbackCommand } = await import("./readback.mjs");
|
|
1303
|
+
const { exitCode, text } = runReadbackCommand(args);
|
|
1304
|
+
if (exitCode === 0) process.stdout.write(text);
|
|
1305
|
+
else {
|
|
1306
|
+
process.stderr.write(text);
|
|
1307
|
+
process.exitCode = 2;
|
|
1308
|
+
}
|
|
1309
|
+
return;
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1148
1312
|
if (command === "validate-assembly-report") {
|
|
1149
1313
|
const reportPath = requireArg(args, "report");
|
|
1150
1314
|
const result = validateAssemblyReport(readJson(resolve(reportPath)));
|
|
@@ -1160,8 +1324,8 @@ async function dispatch(command, args, recorder = NOOP_RECORDER, ambient = null,
|
|
|
1160
1324
|
}
|
|
1161
1325
|
|
|
1162
1326
|
if (command === "install-skills") {
|
|
1163
|
-
if (args.target === true) throw
|
|
1164
|
-
if (args.platform === true) throw
|
|
1327
|
+
if (args.target === true) throw refused("Missing value for --target");
|
|
1328
|
+
if (args.platform === true) throw refused("Missing value for --platform");
|
|
1165
1329
|
const result = installSkills(args.target, Boolean(args["dry-run"]), args.platform);
|
|
1166
1330
|
writeResult(result, args, 0);
|
|
1167
1331
|
return;
|
|
@@ -1172,7 +1336,7 @@ async function dispatch(command, args, recorder = NOOP_RECORDER, ambient = null,
|
|
|
1172
1336
|
// Spelled as inequalities: knownCommands() harvests the top-level
|
|
1173
1337
|
// command literals from this function by an equality pattern that a
|
|
1174
1338
|
// subcommand equality would also match.
|
|
1175
|
-
if (subcommand !== "sync" && subcommand !== "parity") throw
|
|
1339
|
+
if (subcommand !== "sync" && subcommand !== "parity") throw refused("Unknown page-kit subcommand. Use: campaigns-os page-kit sync --packet <campaign-runtime.build.json> [--dry-run] [--json], or campaigns-os page-kit parity --packet <campaign-runtime.build.json> [--report <json>] [--json].");
|
|
1176
1340
|
const parity = subcommand !== "sync";
|
|
1177
1341
|
const result = parity ? pageKitParityCommand(args) : pageKitSyncCommand(args);
|
|
1178
1342
|
if (args.json) console.log(JSON.stringify(result, null, 2));
|
|
@@ -1185,7 +1349,7 @@ async function dispatch(command, args, recorder = NOOP_RECORDER, ambient = null,
|
|
|
1185
1349
|
const subcommand = args._[1] || null;
|
|
1186
1350
|
// Inequality on purpose: knownCommands() harvests top-level command
|
|
1187
1351
|
// literals by an equality pattern a subcommand equality would also match.
|
|
1188
|
-
if (subcommand !== "derive") throw
|
|
1352
|
+
if (subcommand !== "derive") throw refused("Unknown spec subcommand. Use: campaigns-os spec derive --packet <campaign-runtime.build.json> [--dry-run] [--json].");
|
|
1189
1353
|
const result = await specDeriveWithMapWriteback(args);
|
|
1190
1354
|
if (args.json) console.log(JSON.stringify(result, null, 2));
|
|
1191
1355
|
else for (const line of specDeriveWriteMapTextLines(result)) console.log(line);
|
|
@@ -1194,8 +1358,11 @@ async function dispatch(command, args, recorder = NOOP_RECORDER, ambient = null,
|
|
|
1194
1358
|
}
|
|
1195
1359
|
|
|
1196
1360
|
if (command === "tooling") {
|
|
1197
|
-
const result =
|
|
1198
|
-
|
|
1361
|
+
const result = await toolingStatusCommand(args);
|
|
1362
|
+
// The revision line is a header, so a mismatch is stated before the status
|
|
1363
|
+
// an operator would otherwise read as fine — and the full status still
|
|
1364
|
+
// prints, because the exit code is set after the render, not instead of it.
|
|
1365
|
+
writeResult(result, args, result.ok ? 0 : 2, { headerLines: [...skillsRevisionTextLines(result), ...pinTextLines(result)] });
|
|
1199
1366
|
return;
|
|
1200
1367
|
}
|
|
1201
1368
|
|
|
@@ -1217,7 +1384,9 @@ async function dispatch(command, args, recorder = NOOP_RECORDER, ambient = null,
|
|
|
1217
1384
|
// command a QA run prints has to agree with the run_id this session will
|
|
1218
1385
|
// later close and remit under.
|
|
1219
1386
|
const result = await runQaCli(args, { ambient });
|
|
1220
|
-
|
|
1387
|
+
// nextStage requires a packet. Guard its optional, swallowed progress
|
|
1388
|
+
// probe explicitly so it cannot construct a refusal in that try block.
|
|
1389
|
+
if (args._[1] === "run" && result?.verdict && isNonEmptyString(args.packet) && recordQaStageOutcome(args, result)) {
|
|
1221
1390
|
// Observe committed QA before the existing closeout; this cannot close
|
|
1222
1391
|
// a run or change the QA disposition. Reuse the canonical picker.
|
|
1223
1392
|
try {
|
|
@@ -1235,6 +1404,9 @@ async function dispatch(command, args, recorder = NOOP_RECORDER, ambient = null,
|
|
|
1235
1404
|
}
|
|
1236
1405
|
|
|
1237
1406
|
if (command === "run-record") {
|
|
1407
|
+
// These are the operator's own argv. Internal closeouts call
|
|
1408
|
+
// runRecordCommand directly and retain their pre-1.43.1 flag handling.
|
|
1409
|
+
validateRunRecordArgv(args);
|
|
1238
1410
|
await runRecordCommand(args, ambient);
|
|
1239
1411
|
return;
|
|
1240
1412
|
}
|
|
@@ -1252,7 +1424,7 @@ async function dispatch(command, args, recorder = NOOP_RECORDER, ambient = null,
|
|
|
1252
1424
|
|
|
1253
1425
|
const suggestion = closestCommand(command);
|
|
1254
1426
|
const didYouMean = suggestion ? ` Did you mean "${suggestion}"?` : "";
|
|
1255
|
-
throw
|
|
1427
|
+
throw refused(
|
|
1256
1428
|
`Unknown command: ${command}.${didYouMean} Run \`campaigns-os --help\` to see available commands.`,
|
|
1257
1429
|
);
|
|
1258
1430
|
}
|
|
@@ -1277,12 +1449,25 @@ function parseArgs(argv) {
|
|
|
1277
1449
|
return args;
|
|
1278
1450
|
}
|
|
1279
1451
|
|
|
1452
|
+
// The shared "missing required flag" refusal. Every call site resolves flags
|
|
1453
|
+
// at the top of its handler, before the command reads or writes anything, so
|
|
1454
|
+
// this is always an up-front refusal and carries the tag.
|
|
1280
1455
|
function requireArg(args, key) {
|
|
1281
1456
|
const value = args[key];
|
|
1282
|
-
if (!isNonEmptyString(value)) throw
|
|
1457
|
+
if (!isNonEmptyString(value)) throw refused(`Missing required --${key}`);
|
|
1283
1458
|
return value;
|
|
1284
1459
|
}
|
|
1285
1460
|
|
|
1461
|
+
// `--dry-run` is a bare flag on every command that takes one. The shared
|
|
1462
|
+
// parser would read a following token as its value, so `--dry-run true` must
|
|
1463
|
+
// fail rather than quietly become a real write or a real send.
|
|
1464
|
+
function isDryRun(args) {
|
|
1465
|
+
if (Object.hasOwn(args, "dry-run") && args["dry-run"] !== true) {
|
|
1466
|
+
throw refused(`--dry-run takes no value (got ${JSON.stringify(args["dry-run"])}); write \`--dry-run\` on its own, after the other flags.`);
|
|
1467
|
+
}
|
|
1468
|
+
return args["dry-run"] === true;
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1286
1471
|
function isObject(value) {
|
|
1287
1472
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
1288
1473
|
}
|
|
@@ -1355,7 +1540,7 @@ async function resolveSpecPath(args, opts = {}) {
|
|
|
1355
1540
|
const mapId = String(args["map-id"]).trim();
|
|
1356
1541
|
const targetRepo = opts.targetRepo || (args.target ? resolve(args.target) : null);
|
|
1357
1542
|
if (!targetRepo) {
|
|
1358
|
-
throw
|
|
1543
|
+
throw refused("--map-id requires --target (so the fetched spec can be cached under <target>/.campaign-runtime/).");
|
|
1359
1544
|
}
|
|
1360
1545
|
const proxyBase = optionalString(args["proxy-base"], DEFAULT_PROXY_BASE);
|
|
1361
1546
|
const cacheDir = join(targetRepo, ".campaign-runtime", "fetched-specs");
|
|
@@ -1374,7 +1559,7 @@ async function resolveSpecPath(args, opts = {}) {
|
|
|
1374
1559
|
algorithm: "map-store-v1", local_spec_material_hash: specMaterialHash(spec) },
|
|
1375
1560
|
};
|
|
1376
1561
|
}
|
|
1377
|
-
throw
|
|
1562
|
+
throw refused(
|
|
1378
1563
|
"Either --spec <path> or --map-id <id> is required. " +
|
|
1379
1564
|
"Pass a local CampaignSpec (--spec <path-to-campaignspec.json>) " +
|
|
1380
1565
|
"or fetch one from Map Builder (--map-id <id> --target <page-kit-dir>).",
|
|
@@ -1533,7 +1718,7 @@ function campaignIdentity(spec, args) {
|
|
|
1533
1718
|
|| optionalString(spec.spec_identity?.public_route_slug)
|
|
1534
1719
|
|| optionalString(spec.campaign?.slug)
|
|
1535
1720
|
|| optionalString(spec.campaign?.id);
|
|
1536
|
-
return { mapId, publicRouteSlug };
|
|
1721
|
+
return { mapId, publicRouteSlug, localSpecId: spec.spec_identity?.local_spec_id ?? null };
|
|
1537
1722
|
}
|
|
1538
1723
|
|
|
1539
1724
|
function preferredTemplateFamily(spec) {
|
|
@@ -2146,11 +2331,8 @@ function parseWrapperPolicyFlag(args) {
|
|
|
2146
2331
|
// directory are errors: the operator named the file, so silently falling back
|
|
2147
2332
|
// to filesystem matching would discard the declaration they made.
|
|
2148
2333
|
function parseDesignManifestFlag(args) {
|
|
2149
|
-
const raw = args
|
|
2334
|
+
const raw = requireDesignManifestValue(args);
|
|
2150
2335
|
if (raw == null) return null;
|
|
2151
|
-
if (raw === true || !isNonEmptyString(raw)) {
|
|
2152
|
-
throw new Error("--design-manifest needs a value: the path of a source-html-manifest/v0 JSON file.");
|
|
2153
|
-
}
|
|
2154
2336
|
const path = resolve(raw);
|
|
2155
2337
|
if (!existsSync(path) || !statSync(path).isFile()) {
|
|
2156
2338
|
throw new Error(`Design manifest does not exist or is not a file: ${path}`);
|
|
@@ -2158,6 +2340,14 @@ function parseDesignManifestFlag(args) {
|
|
|
2158
2340
|
return path;
|
|
2159
2341
|
}
|
|
2160
2342
|
|
|
2343
|
+
function requireDesignManifestValue(args) {
|
|
2344
|
+
const raw = args["design-manifest"];
|
|
2345
|
+
if (raw != null && (raw === true || !isNonEmptyString(raw))) {
|
|
2346
|
+
throw new Error("--design-manifest needs a value: the path of a source-html-manifest/v0 JSON file.");
|
|
2347
|
+
}
|
|
2348
|
+
return raw;
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2161
2351
|
// Same precedence the template family uses (docs/build-packet.md
|
|
2162
2352
|
// "Authoring-Time Hints"): an explicit CLI flag beats a declared file hint,
|
|
2163
2353
|
// and with neither the default stands. The vocabulary is the adapter
|
|
@@ -2208,21 +2398,18 @@ function prepareBuild(args, options = {}) {
|
|
|
2208
2398
|
assertDistinctPrepareBuildOutputPaths(prepareBuildCollisionPaths);
|
|
2209
2399
|
guardAssemblyReportOverwrite(reportPath, args);
|
|
2210
2400
|
const spec = readJson(specPath);
|
|
2211
|
-
const { mapId, publicRouteSlug } = campaignIdentity(spec, args);
|
|
2212
|
-
if (!
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
// partway through, so a flag that throws later would leave persistent state
|
|
2222
|
-
// behind for a bad argument.
|
|
2223
|
-
const wrapperPolicyFlag = parseWrapperPolicyFlag(args);
|
|
2401
|
+
const { mapId, publicRouteSlug, localSpecId } = campaignIdentity(spec, args);
|
|
2402
|
+
if (!resolveCampaignIdentity({ map_id: mapId, local_spec_id: localSpecId })) {
|
|
2403
|
+
throw new Error("CampaignSpec requires exactly one identity: a saved spec_identity.map_id, or an agent-authored spec_identity.local_spec_id (1–64 letters, digits, underscores or hyphens). Keep the local ID stable across revisions; do not invent a Map ID.");
|
|
2404
|
+
}
|
|
2405
|
+
if (!publicRouteSlug) throw new Error("CampaignSpec has no public route slug. Set spec_identity.public_route_slug or campaign.slug.");
|
|
2406
|
+
|
|
2407
|
+
// Dispatch validated the argv-only flags before spec resolution. Only the
|
|
2408
|
+
// manifest's filesystem check remains here, before preparation writes.
|
|
2409
|
+
const sourceKind = options.sourceKind;
|
|
2410
|
+
const wrapperPolicyFlag = options.wrapperPolicyFlag;
|
|
2224
2411
|
const designManifestPath = parseDesignManifestFlag(args);
|
|
2225
|
-
const orderPathDepthFlag =
|
|
2412
|
+
const orderPathDepthFlag = options.orderPathDepthFlag;
|
|
2226
2413
|
|
|
2227
2414
|
const activePages = activeSpecPages(spec);
|
|
2228
2415
|
const htmlFiles = collectHtmlFiles(sourceRoot);
|
|
@@ -2444,6 +2631,9 @@ function prepareBuild(args, options = {}) {
|
|
|
2444
2631
|
const packet = {
|
|
2445
2632
|
schema_version: PACKET_SCHEMA,
|
|
2446
2633
|
generated_at: new Date().toISOString(),
|
|
2634
|
+
// The kernel version that prepared this packet: the second pin source
|
|
2635
|
+
// `tooling status` reads when the project declares no exact devDependency.
|
|
2636
|
+
campaigns_os_version: packageVersion(),
|
|
2447
2637
|
campaign: {
|
|
2448
2638
|
public_route_slug: publicRouteSlug,
|
|
2449
2639
|
...(specRouteRoot ? { route_root: specRouteRoot } : {}),
|
|
@@ -2455,7 +2645,8 @@ function prepareBuild(args, options = {}) {
|
|
|
2455
2645
|
},
|
|
2456
2646
|
spec: {
|
|
2457
2647
|
map_id: mapId,
|
|
2458
|
-
|
|
2648
|
+
...(localSpecId ? { local_spec_id: localSpecId } : {}),
|
|
2649
|
+
spec_url: localSpecId ? null : spec.spec_identity?.spec_url || null,
|
|
2459
2650
|
local_path: relFromFile(packetPath, specPath),
|
|
2460
2651
|
},
|
|
2461
2652
|
design_source_package: designSourcePackage.referenceFor(packetPath),
|
|
@@ -2820,6 +3011,7 @@ function createAssemblyReport({
|
|
|
2820
3011
|
status: "prepared",
|
|
2821
3012
|
identity: {
|
|
2822
3013
|
map_id: packet.spec.map_id,
|
|
3014
|
+
...localSpecIdentityFields(packet.spec),
|
|
2823
3015
|
public_route_slug: packet.campaign.public_route_slug,
|
|
2824
3016
|
campaign_directory: packet.campaign.campaign_directory,
|
|
2825
3017
|
live_url_path: packet.campaign.live_url_path,
|
|
@@ -3218,7 +3410,7 @@ function rejectUnknownStandardizeFlags(args) {
|
|
|
3218
3410
|
const valueHint = unknown.some((key) => key.includes("="))
|
|
3219
3411
|
? " A flag takes its value as the next argument (--flag value), not --flag=value."
|
|
3220
3412
|
: "";
|
|
3221
|
-
throw
|
|
3413
|
+
throw refused(
|
|
3222
3414
|
`Unknown flag${unknown.length > 1 ? "s" : ""} for standardize: ${unknown.map((key) => `--${key}`).join(", ")}.${valueHint} Known flags: ${STANDARDIZE_FLAGS.map((key) => `--${key}`).join(", ")}.`,
|
|
3223
3415
|
);
|
|
3224
3416
|
}
|
|
@@ -3227,7 +3419,7 @@ function standardizationReportCommand(args) {
|
|
|
3227
3419
|
rejectUnknownStandardizeFlags(args);
|
|
3228
3420
|
const target = optionalString(args.target);
|
|
3229
3421
|
if (!target) {
|
|
3230
|
-
throw
|
|
3422
|
+
throw refused("standardize requires --target <campaign-repo> (a Page Kit root, a parent repo, or a Campaign Cart application checkout).");
|
|
3231
3423
|
}
|
|
3232
3424
|
const family = optionalString(args.family) || optionalString(args["template-family"]);
|
|
3233
3425
|
const slug = optionalString(args.slug);
|
|
@@ -3283,7 +3475,7 @@ function standardizationReportCommand(args) {
|
|
|
3283
3475
|
function themeCommand(args) {
|
|
3284
3476
|
const subcommand = args._[1] || "inspect";
|
|
3285
3477
|
if (!["inspect", "generate", "waive"].includes(subcommand)) {
|
|
3286
|
-
throw
|
|
3478
|
+
throw refused(`Unknown theme subcommand "${subcommand}". Use: inspect | generate | waive.`);
|
|
3287
3479
|
}
|
|
3288
3480
|
if (subcommand === "waive") return themeWaive(args);
|
|
3289
3481
|
const packetPath = resolve(requireArg(args, "packet"));
|
|
@@ -3325,20 +3517,25 @@ function themeCommand(args) {
|
|
|
3325
3517
|
// improvising past advisory prose.
|
|
3326
3518
|
export function themeWaive(args) {
|
|
3327
3519
|
const packetPath = resolve(requireArg(args, "packet"));
|
|
3520
|
+
const dryRun = isDryRun(args);
|
|
3328
3521
|
const packet = readJson(packetPath);
|
|
3329
3522
|
const reason = optionalString(args.reason);
|
|
3330
|
-
|
|
3523
|
+
// A missing flag, raised after reading nothing but argv and the packet: a
|
|
3524
|
+
// refusal, so the journal records nothing for it (docs/effects.md `*refused*`).
|
|
3525
|
+
if (!reason) throw refused("theme waive requires --reason \"<why the starter palette is acceptable for this campaign>\".");
|
|
3331
3526
|
// The same attribution rule as `checkpoint waive`: a named human, no
|
|
3332
3527
|
// placeholder, an expiry (when given) that lies in the future and is
|
|
3333
3528
|
// recorded. A bound is not demanded here: the theme gate's waiver has always
|
|
3334
3529
|
// been open-ended, and QA re-surfaces the starter palette on every run.
|
|
3335
|
-
|
|
3530
|
+
// A shared validator of argv alone, still ahead of the report read: its
|
|
3531
|
+
// throws are refusals at this call site (the position decides, not the file).
|
|
3532
|
+
const waiver = refusing(() => validateWaiverAttribution({
|
|
3336
3533
|
reason,
|
|
3337
3534
|
waivedBy: args["waived-by"] == null ? null : String(args["waived-by"]),
|
|
3338
3535
|
expiresAt: args["expires-at"] == null ? null : String(args["expires-at"]),
|
|
3339
3536
|
requireBound: false,
|
|
3340
3537
|
label: "theme waive",
|
|
3341
|
-
});
|
|
3538
|
+
}));
|
|
3342
3539
|
const workspace = resolveCampaignWorkspace(packetPath, {
|
|
3343
3540
|
packet,
|
|
3344
3541
|
reportPath: args.report ? resolve(args.report) : undefined,
|
|
@@ -3346,7 +3543,7 @@ export function themeWaive(args) {
|
|
|
3346
3543
|
});
|
|
3347
3544
|
const { reportPath } = workspace;
|
|
3348
3545
|
if (!existsSync(reportPath)) throw new Error(`theme waive needs an assembly report at ${reportPath}; run prepare-build/start first.`);
|
|
3349
|
-
|
|
3546
|
+
const recordWaiver = (report) => {
|
|
3350
3547
|
report.theme = report.theme && isObject(report.theme)
|
|
3351
3548
|
? { ...report.theme, waiver }
|
|
3352
3549
|
: { status: "skipped", css_path: null, load_order: "not-applied", commerce_pages: [], evidence: [], warnings: [], repair_loop_defect: null, waiver };
|
|
@@ -3355,12 +3552,37 @@ export function themeWaive(args) {
|
|
|
3355
3552
|
`Theme gate waived by ${waiver.waived_by} at ${waiver.waived_at}: ${reason}`,
|
|
3356
3553
|
];
|
|
3357
3554
|
return report;
|
|
3358
|
-
}
|
|
3555
|
+
};
|
|
3556
|
+
// Every check above is the real command's; only the commit is skipped. The
|
|
3557
|
+
// readiness block is not reported for a dry run because doctor reads it off
|
|
3558
|
+
// the report on disk, which by construction still has no waiver on it.
|
|
3559
|
+
//
|
|
3560
|
+
// Both modes go through the committing path itself (see
|
|
3561
|
+
// commitWaiverToAssemblyReport): the dry run used to return on the existsSync
|
|
3562
|
+
// check alone, so a torn report was reported as a successful `would_write`
|
|
3563
|
+
// with exit 0 while the real invocation refused with "Assembly Report ... is
|
|
3564
|
+
// not valid JSON" and exit 1, and a report that is not an object was previewed
|
|
3565
|
+
// as writable while the commit refused it. Validation must never be weaker
|
|
3566
|
+
// under --dry-run than without it.
|
|
3567
|
+
commitWaiverToAssemblyReport(workspace, recordWaiver, {
|
|
3359
3568
|
// #171: the waiver changes what doctor would conclude; the retained doctor
|
|
3360
3569
|
// sidecar (if any) now predates it.
|
|
3361
3570
|
command: "theme waive",
|
|
3362
3571
|
staleReason: `A theme-gate waiver was recorded after this doctor snapshot. Re-run ${cmd("doctor")} (or next) for current state.`,
|
|
3363
|
-
});
|
|
3572
|
+
}, { dryRun });
|
|
3573
|
+
if (dryRun) {
|
|
3574
|
+
return {
|
|
3575
|
+
ok: true,
|
|
3576
|
+
status: "dry_run",
|
|
3577
|
+
dry_run: true,
|
|
3578
|
+
action: "theme-waive",
|
|
3579
|
+
gate: "theme_gate",
|
|
3580
|
+
waiver,
|
|
3581
|
+
report_path: reportPath,
|
|
3582
|
+
would_write: reportPath,
|
|
3583
|
+
note: "Dry run: nothing was written and the doctor sidecar was not marked stale. Re-run without --dry-run to record this waiver.",
|
|
3584
|
+
};
|
|
3585
|
+
}
|
|
3364
3586
|
return {
|
|
3365
3587
|
ok: true,
|
|
3366
3588
|
...waiveReadiness(packetPath, reportPath),
|
|
@@ -3378,6 +3600,44 @@ export function themeWaive(args) {
|
|
|
3378
3600
|
// "unknown" fallback. Doctor is re-run rather than patched from the pre-waive
|
|
3379
3601
|
// result because a waiver changes what every other gate concludes about the
|
|
3380
3602
|
// stage. Nothing is persisted here; the sidecar was already marked stale.
|
|
3603
|
+
/**
|
|
3604
|
+
* The one route both waive commands take to the Assembly Report, real or
|
|
3605
|
+
* previewed. `commitAssemblyReport` is called identically in both modes — same
|
|
3606
|
+
* workspace, same mutator, same options — so every check the committing path
|
|
3607
|
+
* makes runs on both: the report must exist, it must parse (a torn one fails by
|
|
3608
|
+
* name), the mutator's own refusals fire, its result must be an Assembly Report
|
|
3609
|
+
* object, and the derived summary is restated over that result. A dry run
|
|
3610
|
+
* differs in one statement: this wrapper — not the mutator — returns `null` to
|
|
3611
|
+
* commitAssemblyReport, which is its "nothing to write" answer, so the report
|
|
3612
|
+
* is not rewritten and the doctor sidecar is not stamped stale. Nothing is
|
|
3613
|
+
* re-implemented and nothing is skipped but the write itself. A mutator that
|
|
3614
|
+
* returns nothing does not get to borrow that sentinel: it is a bug, and it
|
|
3615
|
+
* throws here on both paths.
|
|
3616
|
+
*
|
|
3617
|
+
* The result-shape check and the summary restatement sit here rather than being
|
|
3618
|
+
* left to commitAssemblyReport alone because they must run in BOTH modes and
|
|
3619
|
+
* commitAssemblyReport reaches its own copies only on the way to the write.
|
|
3620
|
+
* Running them here means one message and one order, not two: the real path
|
|
3621
|
+
* now fails on this line and never on the copy in stage-ledger.mjs, so the two
|
|
3622
|
+
* cannot drift into different text.
|
|
3623
|
+
*/
|
|
3624
|
+
export function commitWaiverToAssemblyReport(workspace, mutate, options, { dryRun = false } = {}) {
|
|
3625
|
+
const previewOrCommit = (report) => {
|
|
3626
|
+
const mutated = mutate(report);
|
|
3627
|
+
// null and undefined are refused here rather than forwarded. Both waive
|
|
3628
|
+
// mutators always return the report they built, and commitAssemblyReport
|
|
3629
|
+
// reads a null result as "nothing to write" — so a mutator that forgot to
|
|
3630
|
+
// return would skip the write silently while the command still reported the
|
|
3631
|
+
// waiver recorded. That is a bug in the mutator and fails loudly. The dry
|
|
3632
|
+
// run's own "do not write" is this wrapper's null below, not the mutator's.
|
|
3633
|
+
if (!isPlainObject(mutated)) throw new TypeError("commitAssemblyReport mutate(report) must return an Assembly Report object.");
|
|
3634
|
+
if (!dryRun) return mutated;
|
|
3635
|
+
applyDerivedAssemblyReportSummary(mutated);
|
|
3636
|
+
return null;
|
|
3637
|
+
};
|
|
3638
|
+
return commitAssemblyReport(workspace, previewOrCommit, options);
|
|
3639
|
+
}
|
|
3640
|
+
|
|
3381
3641
|
function waiveReadiness(packetPath, reportPath) {
|
|
3382
3642
|
const doctor = doctorPacket(packetPath, { reportPath });
|
|
3383
3643
|
return { status: doctor.status, next_stage: doctor.next?.stage || null, next_stage_reason: doctor.next?.reason || null };
|
|
@@ -3400,14 +3660,14 @@ function requireValidPolishCaptureReport(report, reportPath) {
|
|
|
3400
3660
|
export async function polishCaptureCommand(args, options = {}) {
|
|
3401
3661
|
const subcommand = args?._?.[1] || "help";
|
|
3402
3662
|
if (subcommand !== "capture") {
|
|
3403
|
-
throw
|
|
3663
|
+
throw refused(
|
|
3404
3664
|
`Unknown polish subcommand. Use: ${cmd("polish")} capture --packet <campaign-runtime.build.json> --base-url <url> [--report <json>] [--headed] [--auth-cookie <cookie>] [--json].`,
|
|
3405
3665
|
);
|
|
3406
3666
|
}
|
|
3407
3667
|
const packetPath = resolve(requireArg(args, "packet"));
|
|
3408
3668
|
const baseUrl = requireArg(args, "base-url");
|
|
3409
|
-
if (args.report === true) throw
|
|
3410
|
-
if (args["auth-cookie"] === true) throw
|
|
3669
|
+
if (args.report === true) throw refused("Missing value for --report");
|
|
3670
|
+
if (args["auth-cookie"] === true) throw refused("Missing value for --auth-cookie");
|
|
3411
3671
|
|
|
3412
3672
|
const packet = readJson(packetPath);
|
|
3413
3673
|
const workspace = resolveCampaignWorkspace(packetPath, {
|
|
@@ -3547,13 +3807,14 @@ function waiveOrRefuse(args, run, { gate = null, registeredGates = [] } = {}) {
|
|
|
3547
3807
|
function checkpointCommand(args) {
|
|
3548
3808
|
const subcommand = args._[1] || "help";
|
|
3549
3809
|
if (subcommand !== "waive") {
|
|
3550
|
-
throw
|
|
3810
|
+
throw refused(`Unknown checkpoint subcommand. Use: ${cmd("checkpoint")} waive --packet <campaign-runtime.build.json> --gate <checkpoint-id> --reason "<why>" --waived-by "<named human>" [--expires-at <ISO>] [--review-condition "<trigger>"]. Registered gates: ${Object.keys(CHECKPOINT_EVALUATORS).join(", ")}.`);
|
|
3551
3811
|
}
|
|
3552
3812
|
return checkpointWaive(args);
|
|
3553
3813
|
}
|
|
3554
3814
|
|
|
3555
3815
|
export function checkpointWaive(args) {
|
|
3556
3816
|
const packetPath = resolve(requireArg(args, "packet"));
|
|
3817
|
+
const dryRun = isDryRun(args);
|
|
3557
3818
|
const gateId = requireArg(args, "gate").trim();
|
|
3558
3819
|
const reason = requireArg(args, "reason");
|
|
3559
3820
|
const waivedBy = requireArg(args, "waived-by");
|
|
@@ -3570,7 +3831,7 @@ export function checkpointWaive(args) {
|
|
|
3570
3831
|
|
|
3571
3832
|
const doctor = doctorPacket(packetPath, { reportPath });
|
|
3572
3833
|
let waiver = null;
|
|
3573
|
-
|
|
3834
|
+
const recordWaiver = (report) => {
|
|
3574
3835
|
const gate = evaluateCheckpointRegistry(CHECKPOINT_EVALUATORS, gateId, { doctor, packet, report });
|
|
3575
3836
|
if (!gate) throw new Error(`Checkpoint gate "${gateId}" has no current evidence; repair the packet/spec/target and re-run doctor.`);
|
|
3576
3837
|
if (gate.status !== "blocked") {
|
|
@@ -3591,10 +3852,30 @@ export function checkpointWaive(args) {
|
|
|
3591
3852
|
`Checkpoint waiver: ${gateId} waived by ${waiver.waived_by} at ${waiver.waived_at}: ${waiver.reason}`,
|
|
3592
3853
|
];
|
|
3593
3854
|
return updated;
|
|
3594
|
-
}
|
|
3855
|
+
};
|
|
3856
|
+
// The gate registry decides waivability from the report, so both modes go
|
|
3857
|
+
// through the committing path itself (see commitWaiverToAssemblyReport): the
|
|
3858
|
+
// report is read and parsed the same way and the very same mutator runs over
|
|
3859
|
+
// it, so every refusal above fires exactly as it would for real. Only the
|
|
3860
|
+
// write and the doctor-sidecar stale stamp are skipped, and with them the
|
|
3861
|
+
// readiness block, which doctor can only read off a report on disk.
|
|
3862
|
+
commitWaiverToAssemblyReport(workspace, recordWaiver, {
|
|
3595
3863
|
command: "checkpoint waive",
|
|
3596
3864
|
staleReason: `A checkpoint waiver was recorded after this doctor snapshot. Re-run ${cmd("doctor")} (or next) for current state.`,
|
|
3597
|
-
});
|
|
3865
|
+
}, { dryRun });
|
|
3866
|
+
if (dryRun) {
|
|
3867
|
+
return {
|
|
3868
|
+
ok: true,
|
|
3869
|
+
status: "dry_run",
|
|
3870
|
+
dry_run: true,
|
|
3871
|
+
action: "checkpoint-waive",
|
|
3872
|
+
gate: gateId,
|
|
3873
|
+
waiver,
|
|
3874
|
+
report_path: reportPath,
|
|
3875
|
+
would_write: reportPath,
|
|
3876
|
+
note: "Dry run: nothing was written and the doctor sidecar was not marked stale. Re-run without --dry-run to record this waiver.",
|
|
3877
|
+
};
|
|
3878
|
+
}
|
|
3598
3879
|
return {
|
|
3599
3880
|
ok: true,
|
|
3600
3881
|
...waiveReadiness(packetPath, reportPath),
|
|
@@ -3626,11 +3907,17 @@ export function doctorPacket(packetPath, options = {}) {
|
|
|
3626
3907
|
// to go by first. Code granularity, because that is the granularity the
|
|
3627
3908
|
// record stores. baseDir is the packet directory, the same root the Run
|
|
3628
3909
|
// Record writes under.
|
|
3910
|
+
// An invalid identity cannot select history. In particular, withholding a
|
|
3911
|
+
// malformed local ID from derived must not turn it into an unfiltered or
|
|
3912
|
+
// Map-only lookup of another campaign's findings.
|
|
3913
|
+
const comparableIdentity = resolveCampaignIdentity(result.derived)
|
|
3914
|
+
&& !result.errors.some(issue => issue.code === "spec.local_identity" || issue.code === "spec.map_id");
|
|
3629
3915
|
result.cause_summary = annotateDoctorIssueCauses({
|
|
3630
3916
|
errors: result.errors,
|
|
3631
3917
|
warnings: result.warnings,
|
|
3632
|
-
baseDir: dirname(resolve(packetPath)),
|
|
3918
|
+
baseDir: comparableIdentity ? dirname(resolve(packetPath)) : null,
|
|
3633
3919
|
mapId: result.derived?.map_id || null,
|
|
3920
|
+
localSpecId: result.derived?.local_spec_id || null,
|
|
3634
3921
|
});
|
|
3635
3922
|
return result;
|
|
3636
3923
|
}
|
|
@@ -3650,6 +3937,7 @@ function inspectDoctorPacket(packetPath, { contextPath = undefined, reportPath =
|
|
|
3650
3937
|
const errors = [];
|
|
3651
3938
|
const warnings = [];
|
|
3652
3939
|
const ready = [];
|
|
3940
|
+
const packetIdentity = resolveCampaignIdentity(packet?.spec);
|
|
3653
3941
|
const derived = {
|
|
3654
3942
|
packet_path: packetPath,
|
|
3655
3943
|
// The report this inspection read (null when the caller switched the
|
|
@@ -3657,6 +3945,7 @@ function inspectDoctorPacket(packetPath, { contextPath = undefined, reportPath =
|
|
|
3657
3945
|
// different file.
|
|
3658
3946
|
assembly_report_path: typeof resolvedReportPath === "string" ? resolvedReportPath : null,
|
|
3659
3947
|
map_id: packet?.spec?.map_id || null,
|
|
3948
|
+
...(packetIdentity?.kind === "local_spec" ? { local_spec_id: packetIdentity.id } : {}),
|
|
3660
3949
|
public_route_slug: packet?.campaign?.public_route_slug || null,
|
|
3661
3950
|
template_family: packet?.assembly?.template_family || null,
|
|
3662
3951
|
source_root: null,
|
|
@@ -4090,7 +4379,10 @@ function validatePacket(packet, packetPath, errors, warnings, ready, derived, bu
|
|
|
4090
4379
|
|
|
4091
4380
|
requireString(packet, errors, "campaign.public_route_slug");
|
|
4092
4381
|
requireBoolean(packet, errors, "campaign.allowed_domains_confirmed");
|
|
4093
|
-
|
|
4382
|
+
if (!resolveCampaignIdentity(packet.spec)) addIssue(errors, packet.spec?.local_spec_id != null ? "spec.local_identity" : "spec.map_id", "Packet spec requires exactly one valid map_id or local_spec_id.", { kind: packet.spec?.local_spec_id != null ? "local_spec" : "saved_map" });
|
|
4383
|
+
if (packet.spec?.local_spec_id != null && (packet.spec.spec_url != null || !isNonEmptyString(packet.spec.local_path))) {
|
|
4384
|
+
addIssue(errors, "spec.local_identity", "Local-spec packets require a local_path and no saved-Map spec_url.");
|
|
4385
|
+
}
|
|
4094
4386
|
if (!synthesizedBuiltSite) {
|
|
4095
4387
|
requireString(packet, errors, "source_html.root");
|
|
4096
4388
|
requireArray(packet, errors, "source_html.pages");
|
|
@@ -4276,8 +4568,11 @@ function validatePacket(packet, packetPath, errors, warnings, ready, derived, bu
|
|
|
4276
4568
|
buildState.specStatus = specStatus;
|
|
4277
4569
|
if (specStatus === "ok") {
|
|
4278
4570
|
const specMapId = spec.spec_identity?.map_id || spec.map_id;
|
|
4279
|
-
if (specMapId && specMapId !== packet.spec.map_id)
|
|
4280
|
-
|
|
4571
|
+
if ((specMapId && specMapId !== packet.spec.map_id)
|
|
4572
|
+
|| ((spec.spec_identity?.local_spec_id != null || packet.spec?.local_spec_id != null)
|
|
4573
|
+
&& !campaignIdentitiesMatch(campaignSpecIdentity(spec), packet.spec))) {
|
|
4574
|
+
const localIdentity = spec.spec_identity?.local_spec_id != null || packet.spec?.local_spec_id != null;
|
|
4575
|
+
addIssue(errors, localIdentity ? "spec.local_identity" : "spec.map_id", "Packet identity does not match the CampaignSpec map_id/local_spec_id.", { kind: localIdentity ? "local_spec" : "saved_map" });
|
|
4281
4576
|
}
|
|
4282
4577
|
ready.push("Local CampaignSpec parsed");
|
|
4283
4578
|
runDoctorChecks(SPEC_DOCTOR_CHECKS, { packet, packetPath, spec, targetRepo, errors, warnings, ready, derived, buildState });
|
|
@@ -4685,16 +4980,10 @@ export function pageKitSyncCommand(args) {
|
|
|
4685
4980
|
const unknown = Object.keys(args).filter((key) => key !== "_" && !PAGE_KIT_SYNC_FLAGS.includes(key));
|
|
4686
4981
|
if (unknown.length) {
|
|
4687
4982
|
const valueHint = unknown.some((key) => key.includes("=")) ? " A flag takes its value as the next argument (--flag value), not --flag=value." : "";
|
|
4688
|
-
throw
|
|
4983
|
+
throw refused(`Unknown flag${unknown.length > 1 ? "s" : ""} for page-kit sync: ${unknown.map((key) => `--${key}`).join(", ")}.${valueHint} Known flags: ${PAGE_KIT_SYNC_FLAGS.map((key) => `--${key}`).join(", ")}.`);
|
|
4689
4984
|
}
|
|
4690
4985
|
const packetPath = resolve(requireArg(args, "packet"));
|
|
4691
|
-
|
|
4692
|
-
// token as its value, so `--dry-run true` must fail rather than quietly
|
|
4693
|
-
// become a real write.
|
|
4694
|
-
if (Object.hasOwn(args, "dry-run") && args["dry-run"] !== true) {
|
|
4695
|
-
throw new Error(`--dry-run takes no value (got ${JSON.stringify(args["dry-run"])}); write \`--dry-run\` on its own, after the other flags.`);
|
|
4696
|
-
}
|
|
4697
|
-
const dryRun = args["dry-run"] === true;
|
|
4986
|
+
const dryRun = isDryRun(args);
|
|
4698
4987
|
const result = {
|
|
4699
4988
|
ok: false,
|
|
4700
4989
|
action: "page-kit sync",
|
|
@@ -4778,6 +5067,9 @@ export function pageKitSyncCommand(args) {
|
|
|
4778
5067
|
addIssue(result.errors, "page_kit.sync.spec_identity_mismatch", `CampaignSpec identifies route "${singleLineField(specSlug)}" but the packet's campaign.public_route_slug is "${publicRouteSlug}". Point spec.local_path at this campaign's export (or re-run prepare-build from it); nothing was written.`);
|
|
4779
5068
|
} else if (specMapId && packetMapId && specMapId !== packetMapId) {
|
|
4780
5069
|
addIssue(result.errors, "page_kit.sync.spec_identity_mismatch", `CampaignSpec spec_identity.map_id "${singleLineField(specMapId)}" does not match the packet's spec.map_id "${singleLineField(packetMapId)}". Point spec.local_path at this campaign's export (or re-run prepare-build from it); nothing was written.`);
|
|
5070
|
+
} else if ((spec.spec_identity?.local_spec_id != null || packet.spec?.local_spec_id != null)
|
|
5071
|
+
&& !campaignIdentitiesMatch(campaignSpecIdentity(spec), packet.spec)) {
|
|
5072
|
+
addIssue(result.errors, "page_kit.sync.spec_identity_mismatch", "CampaignSpec identity (spec_identity.map_id/local_spec_id) does not match the packet identity. Point spec.local_path at this campaign's spec (or re-run prepare-build from it); nothing was written.");
|
|
4781
5073
|
}
|
|
4782
5074
|
}
|
|
4783
5075
|
|
|
@@ -4918,8 +5210,8 @@ export function pageKitSyncCommand(args) {
|
|
|
4918
5210
|
// refereed by doctor. Exactly the derived fields are written; the rest of the
|
|
4919
5211
|
// spec and every other file are untouched. The store-derived fields (the nine
|
|
4920
5212
|
// campaign.store_* Store Profile fields, slice 2) join the write only behind
|
|
4921
|
-
// --from-store <subdomain>, which reads the store's Admin API
|
|
4922
|
-
//
|
|
5213
|
+
// --from-store <subdomain>, which reads the store's Admin API
|
|
5214
|
+
// through gateway login, or the explicit --store-token-source env:<VAR> break-glass path;
|
|
4923
5215
|
// the default run stays offline. --dry-run prints the same diff and writes
|
|
4924
5216
|
// nothing. Exit 2 when the packet, the spec or the target entry is missing,
|
|
4925
5217
|
// or the store cannot be read.
|
|
@@ -4931,17 +5223,17 @@ const SPEC_DERIVE_FLAGS = Object.freeze(["packet", "dry-run", "json", "report",
|
|
|
4931
5223
|
// mistake as an unknown flag: refused before anything is read).
|
|
4932
5224
|
function parseSpecDeriveStoreFlags(args) {
|
|
4933
5225
|
if (!Object.hasOwn(args, "from-store")) {
|
|
4934
|
-
if (Object.hasOwn(args, "store-token-source")) throw
|
|
5226
|
+
if (Object.hasOwn(args, "store-token-source")) throw refused("--store-token-source only applies with --from-store <subdomain>.");
|
|
4935
5227
|
return null;
|
|
4936
5228
|
}
|
|
4937
5229
|
const subdomain = normalizeStoreSubdomain(args["from-store"] === true ? "" : String(args["from-store"] ?? ""));
|
|
4938
5230
|
if (!subdomain) {
|
|
4939
|
-
throw
|
|
5231
|
+
throw refused(`--from-store takes the store's subdomain (the <store> of <store>.29next.store), got ${JSON.stringify(args["from-store"] === true ? "" : args["from-store"])}.`);
|
|
4940
5232
|
}
|
|
4941
|
-
let tokenEnv =
|
|
5233
|
+
let tokenEnv = null;
|
|
4942
5234
|
if (Object.hasOwn(args, "store-token-source")) {
|
|
4943
5235
|
const parsed = parseStoreTokenSource(args["store-token-source"] === true ? "" : String(args["store-token-source"] ?? ""));
|
|
4944
|
-
if (parsed.problem) throw
|
|
5236
|
+
if (parsed.problem) throw refused(`--store-token-source ${parsed.problem}`);
|
|
4945
5237
|
tokenEnv = parsed.env;
|
|
4946
5238
|
}
|
|
4947
5239
|
return { subdomain, token_env: tokenEnv };
|
|
@@ -4950,23 +5242,22 @@ function parseSpecDeriveStoreFlags(args) {
|
|
|
4950
5242
|
// `spec derive --from-store`: resolve the credential, read the store, then
|
|
4951
5243
|
// run the same command with the store read in hand. The only network the
|
|
4952
5244
|
// command ever does happens here, and the token never leaves this function:
|
|
4953
|
-
// the result names the
|
|
4954
|
-
export async function specDeriveFromStoreCommand(args, { fetchImpl = globalThis.fetch, env = process.env } = {}) {
|
|
5245
|
+
// the result names the credential source, never its value.
|
|
5246
|
+
export async function specDeriveFromStoreCommand(args, { fetchImpl = globalThis.fetch, env = process.env, credentials, warn = console.warn } = {}) {
|
|
4955
5247
|
const store = parseSpecDeriveStoreFlags(args);
|
|
4956
5248
|
if (!store) return specDeriveCommand(args);
|
|
4957
|
-
const token = typeof env[store.token_env] === "string" ? env[store.token_env].trim() : "";
|
|
4958
|
-
if (!token) {
|
|
4959
|
-
return specDeriveCommand(args, { store: { ...store, status: "credential_missing", detail: `${store.token_env} is not set (or empty) in the environment; export the store's Admin API access token there (Settings > API Access, scopes store:read and content:read), or name another variable with --store-token-source env:<VAR>.` } });
|
|
4960
|
-
}
|
|
4961
|
-
// Local preconditions first: a packet, spec or entry the command would
|
|
4962
|
-
// refuse is refused before the token is sent anywhere. The preflight run
|
|
4963
|
-
// stops at the store gate and writes nothing.
|
|
4964
5249
|
const preflight = specDeriveCommand(args, { store: { ...store, status: "preflight" } });
|
|
4965
5250
|
if (preflight.errors.length) return preflight;
|
|
4966
|
-
|
|
4967
|
-
|
|
4968
|
-
|
|
4969
|
-
|
|
5251
|
+
let read;
|
|
5252
|
+
if (store.token_env) {
|
|
5253
|
+
warn("Warning: explicit Admin environment credentials are a break-glass path. Use campaigns-os login --store <subdomain> and omit --store-token-source for supported gateway reads.");
|
|
5254
|
+
const token = typeof env[store.token_env] === "string" ? env[store.token_env].trim() : "";
|
|
5255
|
+
read = token ? await readStoreProfile({ subdomain: store.subdomain, token, fetchImpl }) : { status: "credential_missing", detail: `${store.token_env} is not set (or empty); use campaigns-os login --store ${store.subdomain}, or explicitly supply the break-glass environment credential.` };
|
|
5256
|
+
} else {
|
|
5257
|
+
const { readGatewayStoreProfile } = await import("./admin-transport.mjs");
|
|
5258
|
+
read = await readGatewayStoreProfile({ subdomain: store.subdomain, credentials, fetchImpl });
|
|
5259
|
+
}
|
|
5260
|
+
// Recheck the original packet/spec identity after the network operation.
|
|
4970
5261
|
return specDeriveCommand(args, { store: { ...store, ...read, expected: { spec_path: preflight.spec_path, public_route_slug: preflight.public_route_slug } } });
|
|
4971
5262
|
}
|
|
4972
5263
|
|
|
@@ -5019,7 +5310,7 @@ export function specDeriveCommand(args, { store: storeRead = null } = {}) {
|
|
|
5019
5310
|
const unknown = Object.keys(args).filter((key) => key !== "_" && !SPEC_DERIVE_FLAGS.includes(key));
|
|
5020
5311
|
if (unknown.length) {
|
|
5021
5312
|
const valueHint = unknown.some((key) => key.includes("=")) ? " A flag takes its value as the next argument (--flag value), not --flag=value." : "";
|
|
5022
|
-
throw
|
|
5313
|
+
throw refused(`Unknown flag${unknown.length > 1 ? "s" : ""} for spec derive: ${unknown.map((key) => `--${key}`).join(", ")}.${valueHint} Known flags: ${SPEC_DERIVE_FLAGS.map((key) => `--${key}`).join(", ")}.`);
|
|
5023
5314
|
}
|
|
5024
5315
|
// The store read is supplied by specDeriveFromStoreCommand; this function
|
|
5025
5316
|
// never touches the network itself, so a --from-store call that reaches it
|
|
@@ -5027,13 +5318,8 @@ export function specDeriveCommand(args, { store: storeRead = null } = {}) {
|
|
|
5027
5318
|
const storeFlags = parseSpecDeriveStoreFlags(args);
|
|
5028
5319
|
if (storeFlags && !storeRead) throw new Error("spec derive --from-store must be dispatched through specDeriveFromStoreCommand (no store read was supplied).");
|
|
5029
5320
|
const packetPath = resolve(requireArg(args, "packet"));
|
|
5030
|
-
|
|
5031
|
-
|
|
5032
|
-
if (Object.hasOwn(args, "dry-run") && args["dry-run"] !== true) {
|
|
5033
|
-
throw new Error(`--dry-run takes no value (got ${JSON.stringify(args["dry-run"])}); write \`--dry-run\` on its own, after the other flags.`);
|
|
5034
|
-
}
|
|
5035
|
-
if (args.report === true) throw new Error("Missing value for --report");
|
|
5036
|
-
const dryRun = args["dry-run"] === true;
|
|
5321
|
+
const dryRun = isDryRun(args);
|
|
5322
|
+
if (args.report === true) throw refused("Missing value for --report");
|
|
5037
5323
|
const result = {
|
|
5038
5324
|
ok: false,
|
|
5039
5325
|
action: "spec derive",
|
|
@@ -5053,7 +5339,7 @@ export function specDeriveCommand(args, { store: storeRead = null } = {}) {
|
|
|
5053
5339
|
not_in_target: [],
|
|
5054
5340
|
stale_hints: [],
|
|
5055
5341
|
store: storeFlags
|
|
5056
|
-
? { subdomain: storeFlags.subdomain, admin_api: adminApiBaseForStore(storeFlags.subdomain), token_source: `env:${storeFlags.token_env}
|
|
5342
|
+
? { subdomain: storeFlags.subdomain, admin_api: adminApiBaseForStore(storeFlags.subdomain), ...(storeFlags.token_env ? {} : { transport: "gateway", endpoint: "https://mcp.nextcommerce.com/admin/" }), token_source: storeFlags.token_env ? `env:${storeFlags.token_env}` : "gateway:login", store_read: null, pages_read: null, primary_domain: null }
|
|
5057
5343
|
: null,
|
|
5058
5344
|
rebound: { build_context: null, assembly_report: null },
|
|
5059
5345
|
errors: [],
|
|
@@ -5127,6 +5413,9 @@ export function specDeriveCommand(args, { store: storeRead = null } = {}) {
|
|
|
5127
5413
|
addIssue(result.errors, "spec.derive.spec_identity_mismatch", `CampaignSpec identifies route "${singleLineField(specSlug)}" but the packet's campaign.public_route_slug is "${publicRouteSlug}". Point spec.local_path at this campaign's export (or re-run prepare-build from it); nothing was written.`);
|
|
5128
5414
|
} else if (specMapId && packetMapId && specMapId !== packetMapId) {
|
|
5129
5415
|
addIssue(result.errors, "spec.derive.spec_identity_mismatch", `CampaignSpec spec_identity.map_id "${singleLineField(specMapId)}" does not match the packet's spec.map_id "${singleLineField(packetMapId)}". Point spec.local_path at this campaign's export (or re-run prepare-build from it); nothing was written.`);
|
|
5416
|
+
} else if ((spec.spec_identity?.local_spec_id != null || packet.spec?.local_spec_id != null)
|
|
5417
|
+
&& !campaignIdentitiesMatch(campaignSpecIdentity(spec), packet.spec)) {
|
|
5418
|
+
addIssue(result.errors, "spec.derive.spec_identity_mismatch", "CampaignSpec identity (spec_identity.map_id/local_spec_id) does not match the packet identity. Point spec.local_path at this campaign's spec (or re-run prepare-build from it); nothing was written.");
|
|
5130
5419
|
}
|
|
5131
5420
|
}
|
|
5132
5421
|
|
|
@@ -5236,8 +5525,8 @@ export function specDeriveCommand(args, { store: storeRead = null } = {}) {
|
|
|
5236
5525
|
return result;
|
|
5237
5526
|
}
|
|
5238
5527
|
if (storeRead && storeRead.status !== "ok") {
|
|
5239
|
-
const codes = { credential_missing: "store_credential_missing", credential_invalid: "store_credential_invalid", unauthorized: "store_unauthorized", not_found: "store_not_found", unreachable: "store_unreachable", invalid: "store_response_invalid" };
|
|
5240
|
-
addIssue(result.errors, `spec.derive.${codes[storeRead.status] || "store_unreachable"}`, `${storeRead.detail} Nothing was written.`, { subdomain: storeRead.subdomain, token_source: `env:${storeRead.token_env}` });
|
|
5528
|
+
const codes = { credential_unavailable: "store_credential_unavailable", credential_missing: "store_credential_missing", credential_invalid: "store_credential_invalid", unauthorized: "store_unauthorized", not_found: "store_not_found", unreachable: "store_unreachable", invalid: "store_response_invalid" };
|
|
5529
|
+
addIssue(result.errors, `spec.derive.${codes[storeRead.status] || "store_unreachable"}`, `${storeRead.detail} Nothing was written.`, { subdomain: storeRead.subdomain, token_source: storeRead.token_env ? `env:${storeRead.token_env}` : "gateway:login" });
|
|
5241
5530
|
return result;
|
|
5242
5531
|
}
|
|
5243
5532
|
const plan = planSpecDerive({ spec, entry, pageFiles, packetBindings, waivedGates, waiversUnknown, publicRouteSlug });
|
|
@@ -5440,7 +5729,7 @@ export function specDeriveTextLines(result) {
|
|
|
5440
5729
|
if (result.spec_path) lines.push(`Spec: ${singleLineField(result.spec_path)}`);
|
|
5441
5730
|
if (result.campaigns_path) lines.push(`Target: ${singleLineField(result.campaigns_path)}[${result.public_route_slug || "<public-route-slug>"}]${result.page_tree ? `, page tree ${singleLineField(result.page_tree)}/` : ""}`);
|
|
5442
5731
|
if (result.store) {
|
|
5443
|
-
lines.push(`Store: ${singleLineField(result.store.admin_api)} (token ${singleLineField(result.store.token_source)}${result.store.primary_domain ? `, primary domain ${singleLineField(result.store.primary_domain)}` : ""}${result.store.pages_read && result.store.pages_read !== "ok" ? `, pages ${result.store.pages_read}` : ""})`);
|
|
5732
|
+
lines.push(`Store: ${singleLineField(result.store.endpoint || result.store.admin_api)} (token ${singleLineField(result.store.token_source)}${result.store.primary_domain ? `, primary domain ${singleLineField(result.store.primary_domain)}` : ""}${result.store.pages_read && result.store.pages_read !== "ok" ? `, pages ${result.store.pages_read}` : ""})`);
|
|
5444
5733
|
}
|
|
5445
5734
|
if (result.errors?.length) {
|
|
5446
5735
|
lines.push("Errors:");
|
|
@@ -5481,9 +5770,9 @@ const PAGE_KIT_PARITY_FLAGS = Object.freeze(["packet", "json", "report"]);
|
|
|
5481
5770
|
export function pageKitParityCommand(args, options = {}) {
|
|
5482
5771
|
const unknown = Object.keys(args).filter((key) => key !== "_" && !PAGE_KIT_PARITY_FLAGS.includes(key));
|
|
5483
5772
|
if (unknown.length) {
|
|
5484
|
-
throw
|
|
5773
|
+
throw refused(`Unknown flag${unknown.length > 1 ? "s" : ""} for page-kit parity: ${unknown.map((key) => `--${key}`).join(", ")}. Known flags: ${PAGE_KIT_PARITY_FLAGS.map((key) => `--${key}`).join(", ")}.`);
|
|
5485
5774
|
}
|
|
5486
|
-
if (args.report === true) throw
|
|
5775
|
+
if (args.report === true) throw refused("Missing value for --report");
|
|
5487
5776
|
const packetPath = resolve(requireArg(args, "packet"));
|
|
5488
5777
|
const result = {
|
|
5489
5778
|
ok: false,
|
|
@@ -5911,15 +6200,15 @@ function validateSpecPackageAvailability(spec, warnings, ready) {
|
|
|
5911
6200
|
|
|
5912
6201
|
function validateSpecIdentityExport(spec, warnings, ready) {
|
|
5913
6202
|
const identity = spec?.spec_identity;
|
|
5914
|
-
if (isObject(identity) &&
|
|
5915
|
-
ready.push(
|
|
6203
|
+
if (isObject(identity) && resolveCampaignIdentity(identity) && isNonEmptyString(identity.public_route_slug)) {
|
|
6204
|
+
ready.push(`CampaignSpec spec_identity includes ${identity.local_spec_id ? "local_spec_id" : "map_id"} and public_route_slug`);
|
|
5916
6205
|
return;
|
|
5917
6206
|
}
|
|
5918
6207
|
|
|
5919
6208
|
addIssue(
|
|
5920
6209
|
warnings,
|
|
5921
6210
|
"spec_identity.export",
|
|
5922
|
-
"CampaignSpec is missing complete spec_identity
|
|
6211
|
+
"CampaignSpec is missing complete spec_identity: declare map_id for a saved Map or local_spec_id for a local spec, plus public_route_slug. CLI identity overrides should stay diagnostic-only."
|
|
5923
6212
|
);
|
|
5924
6213
|
}
|
|
5925
6214
|
|
|
@@ -8802,7 +9091,12 @@ function validateAssemblyReport(report, { checkSourcePackageFreshness = true } =
|
|
|
8802
9091
|
if (report.schema_version !== REPORT_SCHEMA) addIssue(errors, "schema_version", `Expected ${REPORT_SCHEMA}.`);
|
|
8803
9092
|
else ready.push(`Assembly report schema ${REPORT_SCHEMA}`);
|
|
8804
9093
|
for (const path of ["run_id", "generated_at", "status", "identity.map_id", "identity.public_route_slug", "inputs.packet_path", "template_family.value"]) {
|
|
8805
|
-
|
|
9094
|
+
if (path === "identity.map_id") {
|
|
9095
|
+
if (!resolveCampaignIdentity(report.identity)) addIssue(errors, "identity.map_id", "Assembly Report requires exactly one valid map_id or local_spec_id.");
|
|
9096
|
+
} else requireString(report, errors, path);
|
|
9097
|
+
}
|
|
9098
|
+
if (report.identity?.local_spec_id != null && !/^sha256:[0-9a-f]{64}$/.test(report.identity.spec_material_hash || "")) {
|
|
9099
|
+
addIssue(errors, "identity.spec_material_hash", "Local-spec reports require a current SHA-256 material spec hash.");
|
|
8806
9100
|
}
|
|
8807
9101
|
const stages = report.stages;
|
|
8808
9102
|
if (!isObject(stages)) {
|
|
@@ -9052,13 +9346,14 @@ function nextPrepareBuildBindingIssues({
|
|
|
9052
9346
|
const expectedSlug = optionalString(packet.campaign?.public_route_slug);
|
|
9053
9347
|
const recordedMapId = optionalString(report.identity?.map_id);
|
|
9054
9348
|
const recordedSlug = optionalString(report.identity?.public_route_slug);
|
|
9055
|
-
if (
|
|
9349
|
+
if (!campaignIdentitiesMatch(packet.spec, report.identity) || recordedSlug !== expectedSlug) {
|
|
9056
9350
|
push(
|
|
9057
9351
|
"next.prepare_build.report_campaign_mismatch",
|
|
9058
9352
|
"Assembly Report campaign identity does not match the current Build Packet.",
|
|
9059
9353
|
{
|
|
9060
|
-
|
|
9061
|
-
|
|
9354
|
+
// Failed identity fields are diagnostic data, never adopted evidence.
|
|
9355
|
+
expected: { map_id: expectedMapId, local_spec_id: packet.spec?.local_spec_id ?? null, public_route_slug: expectedSlug },
|
|
9356
|
+
recorded: { map_id: recordedMapId, local_spec_id: report.identity?.local_spec_id ?? null, public_route_slug: recordedSlug },
|
|
9062
9357
|
},
|
|
9063
9358
|
);
|
|
9064
9359
|
}
|
|
@@ -9467,6 +9762,9 @@ function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate
|
|
|
9467
9762
|
}
|
|
9468
9763
|
|
|
9469
9764
|
export function nextStage(stage, args, ambient = null) {
|
|
9765
|
+
if (stage !== null && !NEXT_STAGE_ORDER.includes(stage)) {
|
|
9766
|
+
throw refused(`Unknown next stage: ${stage}. Accepted stages: ${NEXT_STAGE_ORDER.join(", ")}.`);
|
|
9767
|
+
}
|
|
9470
9768
|
const packetPath = resolve(requireArg(args, "packet"));
|
|
9471
9769
|
// A custom prepare-build report is recorded on the Build Context relative
|
|
9472
9770
|
// to the target repo. Packet-only `next` must follow that durable pointer;
|
|
@@ -9672,8 +9970,6 @@ export function nextStage(stage, args, ambient = null) {
|
|
|
9672
9970
|
addPolishCheckpointGateErrors(errors, polishCheckpointGate, "qa");
|
|
9673
9971
|
addThemeGateErrors(errors, themeGate, "qa");
|
|
9674
9972
|
prompt = qaPrompt(packetPath, reportPath, packet);
|
|
9675
|
-
} else {
|
|
9676
|
-
throw new Error(`Unknown next stage: ${stage}`);
|
|
9677
9973
|
}
|
|
9678
9974
|
const status = errors.length
|
|
9679
9975
|
? "blocked"
|
|
@@ -10380,7 +10676,7 @@ function qaPrompt(packetPath, reportPath, packet) {
|
|
|
10380
10676
|
const briefPath = packet.build_brief?.normalized_path || "(missing)";
|
|
10381
10677
|
return `Use next-campaigns-qa for this deployed campaign.
|
|
10382
10678
|
|
|
10383
|
-
Map ID: ${packet.spec.map_id}
|
|
10679
|
+
${packet.spec.local_spec_id ? "Local spec ID" : "Map ID"}: ${packet.spec.local_spec_id || packet.spec.map_id}
|
|
10384
10680
|
Base URL: ${url}
|
|
10385
10681
|
Build Packet: ${packetPath}
|
|
10386
10682
|
Assembly Report: ${reportPath}
|
|
@@ -10784,7 +11080,7 @@ function resolveSkillInstallTargets(targetArg = null, platformArg = null) {
|
|
|
10784
11080
|
: SKILL_PLATFORMS.filter((platform) => platform.id === requested);
|
|
10785
11081
|
|
|
10786
11082
|
if (!selected.length) {
|
|
10787
|
-
throw
|
|
11083
|
+
throw refused(`Unknown --platform ${requested}. Use one of: ${skillPlatformHelp()}.`);
|
|
10788
11084
|
}
|
|
10789
11085
|
|
|
10790
11086
|
return selected.map((platform) => ({
|
|
@@ -10847,6 +11143,31 @@ function installSkills(targetArg = null, dryRun = false, platformArg = null) {
|
|
|
10847
11143
|
};
|
|
10848
11144
|
}
|
|
10849
11145
|
|
|
11146
|
+
// A platform directory counts as installed when a skill already sits under one
|
|
11147
|
+
// of the bundled names (current or not), or our own copy under a retired name. A
|
|
11148
|
+
// slot install-skills would only create says nothing about that platform, and
|
|
11149
|
+
// neither does a retired slot another skill occupies. A foreign skill under a
|
|
11150
|
+
// CURRENT bundled name reads as `updated` — install-skills would replace it —
|
|
11151
|
+
// so it does count; the refresh action is then what install-skills would do.
|
|
11152
|
+
const SKILL_ACTIONS_THAT_MARK_A_PLATFORM_INSTALLED = new Set(["unchanged", "updated", "retired"]);
|
|
11153
|
+
|
|
11154
|
+
export function scopeSkillStatusToInstalledPlatforms(status) {
|
|
11155
|
+
if (!Array.isArray(status?.targets)) return { status, scope: "requested", notInstalled: [] };
|
|
11156
|
+
const installedOn = (target) => (target.skills || []).some((skill) => SKILL_ACTIONS_THAT_MARK_A_PLATFORM_INSTALLED.has(skill?.action));
|
|
11157
|
+
const installed = status.targets.filter(installedOn);
|
|
11158
|
+
const describe = (target) => ({
|
|
11159
|
+
platform: target.platform,
|
|
11160
|
+
platform_label: target.platform_label,
|
|
11161
|
+
target_directory: target.target_directory,
|
|
11162
|
+
});
|
|
11163
|
+
if (!installed.length) return { status, scope: "no_platform_installed", notInstalled: status.targets.map(describe) };
|
|
11164
|
+
return {
|
|
11165
|
+
status: { ...status, targets: installed, skills: installed.flatMap((target) => target.skills) },
|
|
11166
|
+
scope: "installed_platforms",
|
|
11167
|
+
notInstalled: status.targets.filter((target) => !installedOn(target)).map(describe),
|
|
11168
|
+
};
|
|
11169
|
+
}
|
|
11170
|
+
|
|
10850
11171
|
const TOOLING_ACTIONABLE_SKILL_ACTIONS = new Set(["created", "updated", "retired"]);
|
|
10851
11172
|
const TOOLING_CLEAN_SKILL_ACTIONS = new Set(["unchanged"]);
|
|
10852
11173
|
|
|
@@ -10871,14 +11192,344 @@ function toolingSkillIdentity(skill) {
|
|
|
10871
11192
|
return `${prefix}${skill?.name || "unknown skill"}`;
|
|
10872
11193
|
}
|
|
10873
11194
|
|
|
11195
|
+
/**
|
|
11196
|
+
* `--skills-revision <value>`: the skills bundle identity an agent read, checked
|
|
11197
|
+
* against the bundle THIS CLI ships.
|
|
11198
|
+
*
|
|
11199
|
+
* The asymmetry is the whole point, and it is why the reported revision is named
|
|
11200
|
+
* `on_disk`. A skill's text is pulled into an agent's context once, at the start
|
|
11201
|
+
* of the task, and is never re-read; the CLI on disk, meanwhile, can be updated
|
|
11202
|
+
* underneath that session by an `npm install`, an `npx` cache refresh, or a
|
|
11203
|
+
* `git pull` in the checkout. So the only honest comparison is "what you are
|
|
11204
|
+
* still reading" against "what is installed right now", and the only honest
|
|
11205
|
+
* remedy for a mismatch is a fresh session — re-running the command cannot pull
|
|
11206
|
+
* the newer skill text into a context that already has the older one.
|
|
11207
|
+
*
|
|
11208
|
+
* The bundle spelling (`<package version>+skills.<n>`) is what every SKILL.md
|
|
11209
|
+
* states on its first body line. The `<skill-id>@<version>` spelling is a
|
|
11210
|
+
* fallback for an agent that carries only the frontmatter of the one skill it
|
|
11211
|
+
* loaded; it is checked against that skill's manifest entry. An id this bundle
|
|
11212
|
+
* does not ship is a mismatch, not a refusal: an agent quoting a skill that is
|
|
11213
|
+
* not here is reading text from some other bundle, which is exactly the
|
|
11214
|
+
* condition this flag exists to catch.
|
|
11215
|
+
*/
|
|
11216
|
+
export function parseSkillsRevisionArg(value) {
|
|
11217
|
+
const text = String(value).trim();
|
|
11218
|
+
const at = text.lastIndexOf("@");
|
|
11219
|
+
if (at > 0 && at < text.length - 1) {
|
|
11220
|
+
return { spelling: "skill", id: text.slice(0, at), version: text.slice(at + 1), requested: text };
|
|
11221
|
+
}
|
|
11222
|
+
return { spelling: "bundle", requested: text };
|
|
11223
|
+
}
|
|
11224
|
+
|
|
11225
|
+
export function evaluateSkillsRevision(value, manifest) {
|
|
11226
|
+
const onDisk = typeof manifest?.bundle_revision === "string" ? manifest.bundle_revision : null;
|
|
11227
|
+
if (value === undefined) {
|
|
11228
|
+
return {
|
|
11229
|
+
status: "unchecked",
|
|
11230
|
+
requested: null,
|
|
11231
|
+
spelling: null,
|
|
11232
|
+
on_disk: onDisk,
|
|
11233
|
+
on_disk_skill: null,
|
|
11234
|
+
message: `unchecked (on disk ${onDisk || "unknown"})`,
|
|
11235
|
+
};
|
|
11236
|
+
}
|
|
11237
|
+
const parsed = parseSkillsRevisionArg(value);
|
|
11238
|
+
if (parsed.spelling === "skill") {
|
|
11239
|
+
const entry = (manifest?.skills || []).find((skill) => skill?.id === parsed.id) || null;
|
|
11240
|
+
const onDiskSkill = entry ? { id: entry.id, version: entry.version ?? null } : null;
|
|
11241
|
+
const match = Boolean(entry) && entry.version === parsed.version;
|
|
11242
|
+
return {
|
|
11243
|
+
status: match ? "match" : "mismatch",
|
|
11244
|
+
requested: parsed.requested,
|
|
11245
|
+
spelling: "skill",
|
|
11246
|
+
on_disk: onDisk,
|
|
11247
|
+
on_disk_skill: onDiskSkill,
|
|
11248
|
+
message: match
|
|
11249
|
+
? `match (${parsed.requested}; bundle ${onDisk || "unknown"})`
|
|
11250
|
+
: `mismatch: loaded ${parsed.requested}, on disk ${
|
|
11251
|
+
onDiskSkill ? `${onDiskSkill.id}@${onDiskSkill.version}` : `no skill named ${parsed.id}`
|
|
11252
|
+
} (bundle ${onDisk || "unknown"}) — start a fresh session`,
|
|
11253
|
+
};
|
|
11254
|
+
}
|
|
11255
|
+
const match = Boolean(onDisk) && onDisk === parsed.requested;
|
|
11256
|
+
return {
|
|
11257
|
+
status: match ? "match" : "mismatch",
|
|
11258
|
+
requested: parsed.requested,
|
|
11259
|
+
spelling: "bundle",
|
|
11260
|
+
on_disk: onDisk,
|
|
11261
|
+
on_disk_skill: null,
|
|
11262
|
+
message: match
|
|
11263
|
+
? `match (${onDisk})`
|
|
11264
|
+
: `mismatch: loaded ${parsed.requested}, on disk ${onDisk || "unknown"} — start a fresh session`,
|
|
11265
|
+
};
|
|
11266
|
+
}
|
|
11267
|
+
|
|
11268
|
+
export function skillsRevisionTextLines(result) {
|
|
11269
|
+
const revision = result?.skills_revision;
|
|
11270
|
+
return revision ? [`Skills revision: ${revision.message}`] : [];
|
|
11271
|
+
}
|
|
11272
|
+
|
|
11273
|
+
// The pin checks (ADR 0002, campaigns-os#466): one executable per project. The
|
|
11274
|
+
// project pin (an exact devDependency or dependency on this package) comes
|
|
11275
|
+
// first, the kernel version the Build Packet records second. Only an exact
|
|
11276
|
+
// version is a pin — a range or tag names no one executable, so it is reported
|
|
11277
|
+
// as `range` and the project counts as unpinned.
|
|
11278
|
+
const PIN_PACKAGE_NAME = "@nextcommerce/campaigns-os";
|
|
11279
|
+
const EXACT_VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
11280
|
+
// npm reads `=1.2.3` and `v1.2.3` as the exact version 1.2.3.
|
|
11281
|
+
const EXACT_PIN_SPEC_RE = /^=?v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)$/;
|
|
11282
|
+
// peerDependencies and optionalDependencies install nothing this project runs.
|
|
11283
|
+
const PIN_KEYS = ["devDependencies", "dependencies"];
|
|
11284
|
+
const PIN_BLOCKING_STATUSES = new Set(["conflicting_pin", "stale_pin"]);
|
|
11285
|
+
|
|
11286
|
+
function exactPinVersion(spec) {
|
|
11287
|
+
return typeof spec === "string" ? EXACT_PIN_SPEC_RE.exec(spec)?.[1] ?? null : null;
|
|
11288
|
+
}
|
|
11289
|
+
|
|
11290
|
+
// An installed package's own manifest (node_modules/<name>/package.json or
|
|
11291
|
+
// node_modules/@<scope>/<name>/package.json) is never the project: run from
|
|
11292
|
+
// inside an install, the walk resolves the enclosing project as if the working
|
|
11293
|
+
// directory were that project. Any other manifest is a candidate, even one with
|
|
11294
|
+
// a node_modules segment higher up its path.
|
|
11295
|
+
function isInstalledPackageDir(dir) {
|
|
11296
|
+
const parent = dirname(dir);
|
|
11297
|
+
if (basename(parent) === "node_modules") return true;
|
|
11298
|
+
return basename(parent).startsWith("@") && basename(dirname(parent)) === "node_modules";
|
|
11299
|
+
}
|
|
11300
|
+
|
|
11301
|
+
function nearestPackageJson(startDir) {
|
|
11302
|
+
for (let dir = resolve(startDir); ; dir = dirname(dir)) {
|
|
11303
|
+
const candidate = join(dir, "package.json");
|
|
11304
|
+
if (!isInstalledPackageDir(dir) && existsSync(candidate) && statSync(candidate).isFile()) return candidate;
|
|
11305
|
+
if (dirname(dir) === dir) return null;
|
|
11306
|
+
}
|
|
11307
|
+
}
|
|
11308
|
+
|
|
11309
|
+
// npm's array form, or the `{ packages: [...] }` object form yarn also reads.
|
|
11310
|
+
function declaresWorkspaces(manifest) {
|
|
11311
|
+
const workspaces = manifest.workspaces;
|
|
11312
|
+
return Array.isArray(workspaces) || (isObject(workspaces) && Array.isArray(workspaces.packages));
|
|
11313
|
+
}
|
|
11314
|
+
|
|
11315
|
+
// An empty or whitespace-only spec pins nothing, so it counts as absent rather
|
|
11316
|
+
// than as a range.
|
|
11317
|
+
function pinSpecsIn(manifest) {
|
|
11318
|
+
return PIN_KEYS.flatMap((key) => {
|
|
11319
|
+
const spec = manifest?.[key]?.[PIN_PACKAGE_NAME];
|
|
11320
|
+
return typeof spec === "string" && spec.trim() ? [{ key, spec: spec.trim() }] : [];
|
|
11321
|
+
});
|
|
11322
|
+
}
|
|
11323
|
+
|
|
11324
|
+
// The project pin is the first exact spec on the walk up from the nearest
|
|
11325
|
+
// manifest, devDependencies before dependencies in each; a range is kept only
|
|
11326
|
+
// if nothing exact turns up. A manifest that names nothing is
|
|
11327
|
+
// neutral and walked through: it cannot supply a pin, so stopping there would
|
|
11328
|
+
// only hide one higher up. The walk ends after a workspace root, at the
|
|
11329
|
+
// filesystem root, and at a manifest it cannot read, with a warning, since
|
|
11330
|
+
// that one might have held the pin.
|
|
11331
|
+
function resolveProjectPin(packageJsonPath, warnings) {
|
|
11332
|
+
let range = null;
|
|
11333
|
+
for (let path = packageJsonPath; path; path = nearestPackageJson(dirname(dirname(path)))) {
|
|
11334
|
+
const problem = {};
|
|
11335
|
+
const manifest = readPinJson(path, problem);
|
|
11336
|
+
if (!manifest) {
|
|
11337
|
+
warnings.push(path === packageJsonPath
|
|
11338
|
+
? `Project pin unavailable: ${path} ${problem.reason}.`
|
|
11339
|
+
: `Project pin walk stopped at ${path}: it ${problem.reason}.`);
|
|
11340
|
+
break;
|
|
11341
|
+
}
|
|
11342
|
+
const specs = pinSpecsIn(manifest);
|
|
11343
|
+
const exact = specs.find(({ spec }) => exactPinVersion(spec));
|
|
11344
|
+
if (exact) return { projectSpec: exact.spec, projectManifest: path, projectKey: exact.key };
|
|
11345
|
+
if (!range && specs.length) range = { projectSpec: specs[0].spec, projectManifest: path, projectKey: specs[0].key };
|
|
11346
|
+
if (declaresWorkspaces(manifest) || dirname(dirname(path)) === dirname(path)) break;
|
|
11347
|
+
}
|
|
11348
|
+
return range || { projectSpec: null, projectManifest: packageJsonPath, projectKey: null };
|
|
11349
|
+
}
|
|
11350
|
+
|
|
11351
|
+
// A malformed file is a missing source plus a warning, never a crash: the pin
|
|
11352
|
+
// line is one report among several and must not take the status down with it.
|
|
11353
|
+
// A leading BOM is valid to npm, so it is valid here.
|
|
11354
|
+
function readPinJson(path, problem = {}) {
|
|
11355
|
+
let text;
|
|
11356
|
+
try {
|
|
11357
|
+
text = readFileSync(path, "utf8");
|
|
11358
|
+
} catch (error) {
|
|
11359
|
+
problem.reason = `could not be read (${error.code || error.message})`;
|
|
11360
|
+
return null;
|
|
11361
|
+
}
|
|
11362
|
+
let value;
|
|
11363
|
+
try {
|
|
11364
|
+
value = JSON.parse(text.replace(/^\uFEFF/, ""));
|
|
11365
|
+
} catch {
|
|
11366
|
+
problem.reason = "is not valid JSON";
|
|
11367
|
+
return null;
|
|
11368
|
+
}
|
|
11369
|
+
if (isObject(value)) return value;
|
|
11370
|
+
problem.reason = "is not a JSON object";
|
|
11371
|
+
return null;
|
|
11372
|
+
}
|
|
11373
|
+
|
|
11374
|
+
/** The project and packet sources `tooling status` compares, read from disk. */
|
|
11375
|
+
export function resolvePinSources(args, { cwd = process.cwd() } = {}) {
|
|
11376
|
+
const warnings = [];
|
|
11377
|
+
const explicitPacket = optionalString(args.packet);
|
|
11378
|
+
let packetPath = explicitPacket ? resolve(cwd, explicitPacket) : null;
|
|
11379
|
+
let packet = null;
|
|
11380
|
+
const packetProblem = {};
|
|
11381
|
+
if (packetPath) {
|
|
11382
|
+
if (!existsSync(packetPath)) throw refused(`Build Packet not found: ${packetPath}`);
|
|
11383
|
+
packet = readPinJson(packetPath, packetProblem);
|
|
11384
|
+
}
|
|
11385
|
+
// An explicit packet names its project: its target repo, whatever the cwd.
|
|
11386
|
+
const projectStart = packet ? targetRepoFor(packetPath, packet) : cwd;
|
|
11387
|
+
const packageJsonPath = nearestPackageJson(projectStart);
|
|
11388
|
+
if (!packetPath) {
|
|
11389
|
+
// The contracted home of the packet is the project root beside package.json
|
|
11390
|
+
// (prepare-build's default --out), the same place readback discovers it.
|
|
11391
|
+
packetPath = join(packageJsonPath ? dirname(packageJsonPath) : resolve(cwd), "campaign-runtime.build.json");
|
|
11392
|
+
if (existsSync(packetPath)) packet = readPinJson(packetPath, packetProblem);
|
|
11393
|
+
}
|
|
11394
|
+
|
|
11395
|
+
const { projectSpec, projectManifest, projectKey } = resolveProjectPin(packageJsonPath, warnings);
|
|
11396
|
+
|
|
11397
|
+
let packetVersion = null;
|
|
11398
|
+
let packetVersionIgnored = null;
|
|
11399
|
+
if (packet && Object.hasOwn(packet, "campaigns_os_version")) {
|
|
11400
|
+
const recorded = packet.campaigns_os_version;
|
|
11401
|
+
if (typeof recorded === "string" && EXACT_VERSION_RE.test(recorded)) {
|
|
11402
|
+
packetVersion = recorded;
|
|
11403
|
+
} else {
|
|
11404
|
+
// Kept verbatim (a non-string as its JSON text) so the Pin: line can say
|
|
11405
|
+
// the field was there and ignored, not that it was absent.
|
|
11406
|
+
packetVersionIgnored = typeof recorded === "string" ? recorded : JSON.stringify(recorded);
|
|
11407
|
+
warnings.push(`Packet campaigns_os_version ignored: ${JSON.stringify(recorded)} in ${packetPath} is not an exact version.`);
|
|
11408
|
+
}
|
|
11409
|
+
} else if (existsSync(packetPath) && !packet) {
|
|
11410
|
+
warnings.push(`Packet pin unavailable: ${packetPath} ${packetProblem.reason}.`);
|
|
11411
|
+
}
|
|
11412
|
+
return {
|
|
11413
|
+
packageJsonPath,
|
|
11414
|
+
projectSpec,
|
|
11415
|
+
projectManifest,
|
|
11416
|
+
projectKey,
|
|
11417
|
+
packetPath: packet ? packetPath : null,
|
|
11418
|
+
packetVersion,
|
|
11419
|
+
packetVersionIgnored,
|
|
11420
|
+
warnings,
|
|
11421
|
+
};
|
|
11422
|
+
}
|
|
11423
|
+
|
|
11424
|
+
/**
|
|
11425
|
+
* Pure: the pin status from the two sources and the running version. The
|
|
11426
|
+
* message names where each version it quotes was read: the key and manifest of
|
|
11427
|
+
* the project pin, the packet file of the recorded version.
|
|
11428
|
+
*/
|
|
11429
|
+
export function evaluatePin({ projectSpec = null, projectManifest = null, projectKey = null, packetVersion = null, packetVersionIgnored = null, packetPath = null, running, force = false }) {
|
|
11430
|
+
const projectVersion = exactPinVersion(projectSpec);
|
|
11431
|
+
const range = projectSpec != null && !projectVersion ? projectSpec : null;
|
|
11432
|
+
const source = projectVersion ? "project" : packetVersion ? "packet" : null;
|
|
11433
|
+
const version = projectVersion || packetVersion || null;
|
|
11434
|
+
const manifest = projectManifest || "package.json";
|
|
11435
|
+
const projectFrom = `${projectKey || "devDependencies"} in ${manifest}`;
|
|
11436
|
+
const packetFrom = `campaigns_os_version in ${packetPath || "the Build Packet"}`;
|
|
11437
|
+
const noPacket = packetVersionIgnored != null
|
|
11438
|
+
? `campaigns_os_version ${JSON.stringify(packetVersionIgnored)} in ${packetPath || "the Build Packet"} is not a bare x.y.z version and was ignored`
|
|
11439
|
+
: packetPath ? `no campaigns_os_version in ${packetPath}` : "no packet version";
|
|
11440
|
+
let status;
|
|
11441
|
+
let message;
|
|
11442
|
+
if (projectVersion && packetVersion && projectVersion !== packetVersion) {
|
|
11443
|
+
status = "conflicting_pin";
|
|
11444
|
+
message = `conflicting_pin — project pins ${projectVersion} (${projectFrom}), packet records ${packetVersion} (${packetFrom})`;
|
|
11445
|
+
} else if (!version) {
|
|
11446
|
+
status = "unpinned";
|
|
11447
|
+
const project = range != null
|
|
11448
|
+
? `project range ${range || '""'} (${projectFrom}) is not an exact version`
|
|
11449
|
+
: projectManifest ? `no project pin in ${projectManifest}` : "no package.json found";
|
|
11450
|
+
message = `unpinned (${project}; ${noPacket})`;
|
|
11451
|
+
} else if (version !== running) {
|
|
11452
|
+
status = "stale_pin";
|
|
11453
|
+
message = `stale_pin — ${source === "project" ? `project pins ${version} (${projectFrom})` : `packet records ${version} (${packetFrom})`}, running ${running}`;
|
|
11454
|
+
} else {
|
|
11455
|
+
status = "match";
|
|
11456
|
+
message = `match (${version} — ${source === "project" ? projectFrom : packetFrom})`;
|
|
11457
|
+
}
|
|
11458
|
+
const forced = force && PIN_BLOCKING_STATUSES.has(status);
|
|
11459
|
+
return {
|
|
11460
|
+
source,
|
|
11461
|
+
version,
|
|
11462
|
+
running,
|
|
11463
|
+
status,
|
|
11464
|
+
range,
|
|
11465
|
+
packet_version: packetVersion,
|
|
11466
|
+
packet_version_ignored: packetVersionIgnored,
|
|
11467
|
+
project_version: projectVersion,
|
|
11468
|
+
project_manifest: projectManifest,
|
|
11469
|
+
project_key: projectKey,
|
|
11470
|
+
forced,
|
|
11471
|
+
message: forced ? `${message} (overridden by --force)` : message,
|
|
11472
|
+
};
|
|
11473
|
+
}
|
|
11474
|
+
|
|
11475
|
+
// Each line names the manifest and key the pin (or range) was read from; with
|
|
11476
|
+
// neither, the nearest manifest and devDependencies, where the ADR puts a pin.
|
|
11477
|
+
function pinAction(pin, sources) {
|
|
11478
|
+
const manifest = pin.project_manifest || "the project's package.json";
|
|
11479
|
+
const key = pin.project_key || "devDependencies";
|
|
11480
|
+
const packet = sources.packetPath || "the Build Packet";
|
|
11481
|
+
if (pin.status === "conflicting_pin") {
|
|
11482
|
+
return `Align the project pin: set ${key}["${PIN_PACKAGE_NAME}"] in ${manifest} to ${pin.packet_version}, or re-run prepare-build with ${pin.project_version} so ${packet} records it. Pass --force to proceed anyway (recorded).`;
|
|
11483
|
+
}
|
|
11484
|
+
if (pin.source === "project") {
|
|
11485
|
+
return `Run the pinned executable (${LOCAL_INVOCATION_PREFIX} from the project), or move the pin: set ${key}["${PIN_PACKAGE_NAME}"] in ${manifest} to ${pin.running} and reinstall. Pass --force to proceed anyway (recorded).`;
|
|
11486
|
+
}
|
|
11487
|
+
const pinStep = pin.project_key
|
|
11488
|
+
? `set ${key}["${PIN_PACKAGE_NAME}"] in ${manifest} to ${pin.running} (it holds the range ${JSON.stringify(pin.range)})`
|
|
11489
|
+
: `add "${PIN_PACKAGE_NAME}": "${pin.running}" to ${key} in ${manifest}`;
|
|
11490
|
+
return `Run the version ${packet} records (${pin.packet_version}), or pin the project: ${pinStep}; the next prepare-build re-stamps ${packet}. Pass --force to proceed anyway (recorded).`;
|
|
11491
|
+
}
|
|
11492
|
+
|
|
11493
|
+
export function pinTextLines(result) {
|
|
11494
|
+
return result?.pin ? [`Pin: ${result.pin.message}`] : [];
|
|
11495
|
+
}
|
|
11496
|
+
|
|
10874
11497
|
function toolingCommand(args) {
|
|
10875
11498
|
const action = args._[1] || "status";
|
|
10876
|
-
if (action !== "status") throw
|
|
10877
|
-
if (args.target === true) throw
|
|
10878
|
-
if (args.platform === true) throw
|
|
11499
|
+
if (action !== "status") throw refused(`Unknown tooling command: ${action}`);
|
|
11500
|
+
if (args.target === true) throw refused("Missing value for --target");
|
|
11501
|
+
if (args.platform === true) throw refused("Missing value for --platform");
|
|
11502
|
+
if (args["skills-revision"] === true) {
|
|
11503
|
+
// The example is the bundle this CLI ships, read from skills.json, so the
|
|
11504
|
+
// refusal never teaches a revision that has since moved.
|
|
11505
|
+
const shipped = readJson(join(ROOT, "skills.json")).bundle_revision;
|
|
11506
|
+
throw refused(`Missing value for --skills-revision. Pass the bundle revision the skill you loaded states on its first body line (for example --skills-revision ${shipped}), or that skill's <skill-id>@<version>.`);
|
|
11507
|
+
}
|
|
11508
|
+
// Bare, like --dry-run: the shared parser would read `--force true` as a
|
|
11509
|
+
// value, and an override must never hinge on how a token happened to parse.
|
|
11510
|
+
if (Object.hasOwn(args, "force") && args.force !== true) {
|
|
11511
|
+
throw refused(`--force takes no value (got ${JSON.stringify(args.force)}); write \`--force\` on its own.`);
|
|
11512
|
+
}
|
|
11513
|
+
// The parser keeps `--no-force` as its own key, so without this it would
|
|
11514
|
+
// run as if unsaid and the operator would never learn it did nothing.
|
|
11515
|
+
if (Object.hasOwn(args, "no-force")) {
|
|
11516
|
+
throw refused("--no-force is not a flag of tooling status; --force is bare and off by default");
|
|
11517
|
+
}
|
|
11518
|
+
if (args.packet === true) throw refused("Missing value for --packet");
|
|
11519
|
+
const pinSources = resolvePinSources(args);
|
|
10879
11520
|
|
|
10880
11521
|
const pkg = readJson(join(ROOT, "package.json"));
|
|
10881
|
-
|
|
11522
|
+
// An explicit --target or --platform (`all` included) is checked as asked.
|
|
11523
|
+
// Without either, only the platform directories that already hold a
|
|
11524
|
+
// Campaigns OS skill are held to this bundle: the documented install is one
|
|
11525
|
+
// platform, and reading the other two as stale told that operator to install
|
|
11526
|
+
// everywhere and exit 2 on a correct setup.
|
|
11527
|
+
const explicitSkillScope = isNonEmptyString(args.target) || isNonEmptyString(args.platform);
|
|
11528
|
+
const allSkillStatus = installSkills(args.target, true, args.platform || "all");
|
|
11529
|
+
const skillScope = explicitSkillScope
|
|
11530
|
+
? { status: allSkillStatus, scope: "requested", notInstalled: [] }
|
|
11531
|
+
: scopeSkillStatusToInstalledPlatforms(allSkillStatus);
|
|
11532
|
+
const skillStatus = skillScope.status;
|
|
10882
11533
|
const skillActions = classifyToolingSkillActions(skillStatus.skills || []);
|
|
10883
11534
|
const staleSkills = skillActions.actionable;
|
|
10884
11535
|
const install = localInstallStatus(ROOT, pkg);
|
|
@@ -10950,9 +11601,30 @@ function toolingCommand(args) {
|
|
|
10950
11601
|
warnings.push(`No pinned commit could be derived for this ${install.mode_label}; the package version is ${pkg.version || "unknown"}. Compare it against the commit you oriented on before relying on it.`);
|
|
10951
11602
|
}
|
|
10952
11603
|
|
|
10953
|
-
if (
|
|
10954
|
-
const
|
|
10955
|
-
|
|
11604
|
+
if (skillScope.scope === "installed_platforms" && skillScope.notInstalled.length) {
|
|
11605
|
+
const checked = skillStatus.targets.map((target) => target.platform_label).join(", ");
|
|
11606
|
+
const skipped = skillScope.notInstalled.map((target) => target.platform_label);
|
|
11607
|
+
ready.push(`Skills checked for ${checked}; ${skipped.join(", ")} ${skipped.length === 1 ? "has" : "have"} no Campaigns OS skills installed and ${skipped.length === 1 ? "was" : "were"} not checked (pass --platform to check one).`);
|
|
11608
|
+
}
|
|
11609
|
+
|
|
11610
|
+
if (skillScope.scope === "no_platform_installed") {
|
|
11611
|
+
// Nothing to refresh: the documented install is one platform, the
|
|
11612
|
+
// harness in use, so name the choice rather than installing everywhere.
|
|
11613
|
+
// The command ends its own sentence and is runnable as printed (Claude
|
|
11614
|
+
// Code, the documented install). The other platforms follow in a separate
|
|
11615
|
+
// sentence of prose: no `<a|b>` template or parenthesis a shell would read
|
|
11616
|
+
// as a redirect or a subshell if the command were copied with it.
|
|
11617
|
+
actions.push(`Install bundled skills for the harness you use: ${cli.invocation_prefix} install-skills --platform claude. Use --platform codex for Codex, or --platform agents for shared agent skills such as Cursor's. Restart local agent sessions afterwards.`);
|
|
11618
|
+
} else if (staleSkills.length) {
|
|
11619
|
+
const stalePlatforms = SKILL_PLATFORMS.map((platform) => platform.id)
|
|
11620
|
+
.filter((id) => staleSkills.some((skill) => skill.platform === id));
|
|
11621
|
+
const invocations = args.target
|
|
11622
|
+
? [["--target", args.target]]
|
|
11623
|
+
: skillScope.scope === "installed_platforms" && stalePlatforms.length < SKILL_PLATFORMS.length
|
|
11624
|
+
? stalePlatforms.map((platform) => ["--platform", platform])
|
|
11625
|
+
: [["--platform", args.platform || "all"]];
|
|
11626
|
+
const commands = invocations.map((skillArgs) => `${cli.invocation_prefix} install-skills ${skillArgs.join(" ")}`);
|
|
11627
|
+
actions.push(`Refresh installed skills: ${commands.join(" and ")}. Restart local agent sessions afterwards.`);
|
|
10956
11628
|
}
|
|
10957
11629
|
|
|
10958
11630
|
if (install.mode === "checkout" && cli.global_binary.status === "not_found") {
|
|
@@ -10960,8 +11632,8 @@ function toolingCommand(args) {
|
|
|
10960
11632
|
} else if (install.mode === "node_modules" && cli.global_binary.status !== "found") {
|
|
10961
11633
|
// A consumer install runs through npm's bin resolution; no PATH ritual.
|
|
10962
11634
|
warnings.push(cli.global_binary.status === "found_other_install"
|
|
10963
|
-
? `The campaigns-os on PATH (${cli.global_binary.path}) is a different install from the one inspected here (${install.location}); run commands as
|
|
10964
|
-
: `campaigns-os is not on PATH; run commands as
|
|
11635
|
+
? `The campaigns-os on PATH (${cli.global_binary.path}) is a different install from the one inspected here (${install.location}); run commands as \`${LOCAL_INVOCATION_PREFIX} <command>\` from the folder that pins this toolkit so this copy runs.`
|
|
11636
|
+
: `campaigns-os is not on PATH; run commands as \`${LOCAL_INVOCATION_PREFIX} <command>\` from the folder that pins this toolkit (npm resolves node_modules/.bin), or call node ${cli.local_bin} directly.`);
|
|
10965
11637
|
} else if (install.mode !== "checkout" && install.mode !== "npx_cache" && cli.global_binary.status === "not_found") {
|
|
10966
11638
|
warnings.push(`campaigns-os is not on PATH; call node ${cli.local_bin} directly.`);
|
|
10967
11639
|
} else if (install.mode !== "checkout" && cli.global_binary.status === "found_other_install") {
|
|
@@ -10980,11 +11652,43 @@ function toolingCommand(args) {
|
|
|
10980
11652
|
warnings.push("This checkout has uncommitted changes; verify they are intentional before publishing or comparing freshness.");
|
|
10981
11653
|
}
|
|
10982
11654
|
|
|
11655
|
+
// The manifest THIS CLI ships, read from its own package root — never from the
|
|
11656
|
+
// working directory, which may be a campaign repo with no skills.json at all.
|
|
11657
|
+
const skillsRevision = evaluateSkillsRevision(args["skills-revision"], readJsonIfExists(join(ROOT, "skills.json")) || {});
|
|
11658
|
+
if (skillsRevision.status === "mismatch") {
|
|
11659
|
+
actions.push(`Start a fresh session: the skills text you are reading is ${skillsRevision.requested}, and this CLI ships ${skillsRevision.on_disk || "an unknown bundle revision"}. Re-running this command cannot refresh skill text already in context.`);
|
|
11660
|
+
}
|
|
11661
|
+
|
|
11662
|
+
// The CLI's own package.json is the running version: the executable this
|
|
11663
|
+
// command is, not whichever one the project would have resolved.
|
|
11664
|
+
const pin = evaluatePin({
|
|
11665
|
+
projectSpec: pinSources.projectSpec,
|
|
11666
|
+
projectManifest: pinSources.projectManifest,
|
|
11667
|
+
projectKey: pinSources.projectKey,
|
|
11668
|
+
packetVersion: pinSources.packetVersion,
|
|
11669
|
+
packetVersionIgnored: pinSources.packetVersionIgnored,
|
|
11670
|
+
packetPath: pinSources.packetPath,
|
|
11671
|
+
running: pkg.version,
|
|
11672
|
+
force: args.force === true,
|
|
11673
|
+
});
|
|
11674
|
+
warnings.push(...pinSources.warnings);
|
|
11675
|
+
const pinBlocks = PIN_BLOCKING_STATUSES.has(pin.status) && !pin.forced;
|
|
11676
|
+
if (PIN_BLOCKING_STATUSES.has(pin.status)) {
|
|
11677
|
+
(pin.forced ? warnings : actions).push(pin.forced
|
|
11678
|
+
? `Pin ${pin.status} overridden by --force; this run proceeds on ${pin.running}.`
|
|
11679
|
+
: pinAction(pin, pinSources));
|
|
11680
|
+
}
|
|
11681
|
+
|
|
10983
11682
|
const gitBlocks = git.status === "ok" && Number.isFinite(git.behind) && git.behind > 0;
|
|
10984
|
-
const ok = !gitBlocks && staleSkills.length === 0;
|
|
11683
|
+
const ok = !gitBlocks && staleSkills.length === 0 && skillsRevision.status !== "mismatch" && !pinBlocks;
|
|
10985
11684
|
return {
|
|
10986
11685
|
ok,
|
|
10987
11686
|
status: ok ? "ready" : "attention_required",
|
|
11687
|
+
// A bare status string, so a consumer can branch on it without reaching into
|
|
11688
|
+
// an object; the detail sits beside it under skills_revision.
|
|
11689
|
+
revision_check: skillsRevision.status,
|
|
11690
|
+
skills_revision: skillsRevision,
|
|
11691
|
+
pin,
|
|
10988
11692
|
install,
|
|
10989
11693
|
package: packageStatus,
|
|
10990
11694
|
git,
|
|
@@ -10992,6 +11696,12 @@ function toolingCommand(args) {
|
|
|
10992
11696
|
skills: {
|
|
10993
11697
|
ok: staleSkills.length === 0,
|
|
10994
11698
|
stale_count: staleSkills.length,
|
|
11699
|
+
// requested: --target/--platform named the scope. installed_platforms:
|
|
11700
|
+
// only platforms with Campaigns OS skills installed were checked, and
|
|
11701
|
+
// not_installed_platforms lists the rest. no_platform_installed: none
|
|
11702
|
+
// had any, so every platform is listed there and was checked.
|
|
11703
|
+
scope: skillScope.scope,
|
|
11704
|
+
not_installed_platforms: skillScope.notInstalled,
|
|
10995
11705
|
status: skillStatus,
|
|
10996
11706
|
},
|
|
10997
11707
|
ready,
|
|
@@ -11000,21 +11710,37 @@ function toolingCommand(args) {
|
|
|
11000
11710
|
};
|
|
11001
11711
|
}
|
|
11002
11712
|
|
|
11713
|
+
export async function toolingStatusCommand(args, options = {}) {
|
|
11714
|
+
const result = toolingCommand(args);
|
|
11715
|
+
const { gatewayLoginStatus } = await import("./admin-transport.mjs");
|
|
11716
|
+
result.gateway_login = await gatewayLoginStatus(options);
|
|
11717
|
+
const auth = result.gateway_login;
|
|
11718
|
+
if (!auth.accounts.length) result.warnings.push(auth.state === "unavailable"
|
|
11719
|
+
? "Gateway credential storage is unavailable or busy. Check user credential directory permissions and keychain access; wait for another campaigns-os process to finish. See docs/gateway-login.md for interrupted-process recovery."
|
|
11720
|
+
: `Gateway login: ${auth.state}. Use ${result.cli.invocation_prefix} login --store <subdomain>.`);
|
|
11721
|
+
for (const account of auth.accounts) (account.state === "logged_in" ? result.ready : result.warnings).push(`Gateway login: ${account.state}; store ${account.store}; access remaining ${account.remaining_seconds}s; gateway ${auth.gateway}; reported version ${account.gateway_version || "unavailable"} (local credential metadata only).`);
|
|
11722
|
+
return result;
|
|
11723
|
+
}
|
|
11724
|
+
|
|
11003
11725
|
export function toolingDiagnose(args, { runTooling = toolingCommand, runDoctor = doctorCommand } = {}) {
|
|
11004
11726
|
let tooling = null;
|
|
11005
11727
|
let doctor = null;
|
|
11006
11728
|
let inspectionFailed = false;
|
|
11007
|
-
const platform = args.platform || "all";
|
|
11008
11729
|
// Inputs are used only by local producers, never echoed, and mutation flags
|
|
11009
11730
|
// are not forwarded. Even an exception's message may contain a secret path.
|
|
11731
|
+
// An unnamed platform is not forwarded, so status checks only the platforms
|
|
11732
|
+
// that hold Campaigns OS skills, as it does when run directly.
|
|
11010
11733
|
try {
|
|
11011
|
-
tooling = runTooling({ _: ["tooling", "status"], platform, ...(typeof args.target === "string" ? { target: args.target } : {}) });
|
|
11734
|
+
tooling = runTooling({ _: ["tooling", "status"], ...(args.platform ? { platform: args.platform } : {}), ...(typeof args.target === "string" ? { target: args.target } : {}) });
|
|
11012
11735
|
} catch { /* unavailable, no raw producer exception in a support export */ }
|
|
11013
11736
|
if (args.packet !== undefined) {
|
|
11014
11737
|
try {
|
|
11015
11738
|
doctor = runDoctor({ packet: args.packet, "no-write": true, ...(typeof args.context === "string" ? { context: args.context } : {}), ...(typeof args.report === "string" ? { report: args.report } : {}) });
|
|
11016
11739
|
} catch { inspectionFailed = true; }
|
|
11017
11740
|
}
|
|
11741
|
+
// `installed` only when status actually narrowed to installed platforms; an
|
|
11742
|
+
// unnamed platform that checked every one (nothing installed) stays `all`.
|
|
11743
|
+
const platform = args.platform || (tooling?.skills?.scope === "installed_platforms" ? "installed" : "all");
|
|
11018
11744
|
return diagnosticExport({ tooling, doctor, platform, inspectionFailed });
|
|
11019
11745
|
}
|
|
11020
11746
|
|
|
@@ -11495,7 +12221,7 @@ async function findingsCommand(args, ambient = null) {
|
|
|
11495
12221
|
if (sub === "harvest") return findingsHarvest(args, ambient);
|
|
11496
12222
|
if (sub === "list") return findingsList(args, ambient);
|
|
11497
12223
|
if (sub === "export") return findingsExport(args, ambient);
|
|
11498
|
-
throw
|
|
12224
|
+
throw refused(`Unknown findings subcommand "${sub}". Use: add | harvest | list | export.`);
|
|
11499
12225
|
}
|
|
11500
12226
|
|
|
11501
12227
|
function resolveFindingsJournalPath(args, ambient = null) {
|
|
@@ -11539,7 +12265,7 @@ async function findingsAdd(args, ambient = null) {
|
|
|
11539
12265
|
summary = answers.summary;
|
|
11540
12266
|
details = answers.details;
|
|
11541
12267
|
} else {
|
|
11542
|
-
throw
|
|
12268
|
+
throw refused(
|
|
11543
12269
|
`findings add is missing required flags: ${missing.join(", ")}. `
|
|
11544
12270
|
+ `Provide them as flags (flags-first for agents/CI), e.g. `
|
|
11545
12271
|
+ `--stage ${FINDING_STAGES[0]} --kind ${FINDING_KINDS[0]} --summary "..."`,
|
|
@@ -11773,7 +12499,7 @@ export async function runSessionCommand(args, ambient = null, sessionHolder = nu
|
|
|
11773
12499
|
if (sub === "start") return runSessionStart(args);
|
|
11774
12500
|
if (sub === "status") return runSessionStatus(args, ambient);
|
|
11775
12501
|
if (sub === "end") return runSessionEnd(args, ambient, sessionHolder);
|
|
11776
|
-
throw
|
|
12502
|
+
throw refused(`Unknown run subcommand "${sub}". Use: start | end | status.`);
|
|
11777
12503
|
}
|
|
11778
12504
|
|
|
11779
12505
|
function writeRunSessionResult(result, args, exitCode) {
|
|
@@ -11957,6 +12683,12 @@ function runSessionProgress(found) {
|
|
|
11957
12683
|
}
|
|
11958
12684
|
|
|
11959
12685
|
async function runSessionEnd(args, ambient = null, sessionHolder = null) {
|
|
12686
|
+
// The closer reads a session, but its own argv refusals still belong to the
|
|
12687
|
+
// invoking command. Check them before entering closeRunSession's nested scope.
|
|
12688
|
+
if (Object.hasOwn(args, "new-run") || Object.hasOwn(args, "run-id")) {
|
|
12689
|
+
throw refused("run end uses the saved session's run ID; --new-run and --run-id are not accepted.");
|
|
12690
|
+
}
|
|
12691
|
+
validateRunRecordArgv(args);
|
|
11960
12692
|
// Use the session resolved once in main() (single source of truth).
|
|
11961
12693
|
const found = ambient;
|
|
11962
12694
|
if (!found) {
|
|
@@ -11987,10 +12719,18 @@ async function runSessionEnd(args, ambient = null, sessionHolder = null) {
|
|
|
11987
12719
|
// `qa run`'s --base-url, --browser or --test-order say nothing about the
|
|
11988
12720
|
// record — and run-record stamps the flag NAMES it was given into the Run
|
|
11989
12721
|
// Record's argv_shape, so carrying them over would file them as run-record's.
|
|
11990
|
-
|
|
12722
|
+
// `dry-run` is on the list for `run end --dry-run`, the one closer whose
|
|
12723
|
+
// invoking command implements the flag; a closer invoked by a command that
|
|
12724
|
+
// does not (the QA auto-end) drops it from extraArgs before calling — see
|
|
12725
|
+
// DRY_RUN_COMMANDS and autoEndRunSessionAfterTerminalQa.
|
|
12726
|
+
export const RUN_RECORD_INHERITABLE_FLAGS = Object.freeze([
|
|
11991
12727
|
"context", "report", "qa-verdict", "journal", "surfaces", "primary-surface", "surface-confidence",
|
|
11992
12728
|
"agent-input-tokens", "agent-output-tokens", "agent-tool-output-tokens", "agent-total-tokens", "agent-elapsed-ms", "agent-model", "agent-usage-source",
|
|
11993
|
-
"no-remit", "no-write", "proxy-base", "json",
|
|
12729
|
+
"no-remit", "no-write", "proxy-base", "dry-run", "json",
|
|
12730
|
+
]);
|
|
12731
|
+
const RUN_RECORD_BOOLEAN_INHERITABLE_FLAGS = new Set(["no-remit", "no-write", "dry-run", "json"]);
|
|
12732
|
+
const RUN_RECORD_INTEGER_INHERITABLE_FLAGS = new Set([
|
|
12733
|
+
"agent-input-tokens", "agent-output-tokens", "agent-tool-output-tokens", "agent-total-tokens", "agent-elapsed-ms",
|
|
11994
12734
|
]);
|
|
11995
12735
|
|
|
11996
12736
|
// The run-record argv that closes `session`: its run_id and journal, the
|
|
@@ -12018,12 +12758,23 @@ export function runSessionEndArgs(session, packet, extraArgs = {}) {
|
|
|
12018
12758
|
async function closeRunSession(found, { packet, extraArgs = {}, silent = false, promptForConsent = true, onError = null } = {}) {
|
|
12019
12759
|
const endArgs = runSessionEndArgs(found.session, packet, extraArgs);
|
|
12020
12760
|
try {
|
|
12021
|
-
|
|
12022
|
-
|
|
12761
|
+
// No internal closeout currently constructs a refusal before its invoking
|
|
12762
|
+
// command journals: the sweep forwards only remit controls tolerated by
|
|
12763
|
+
// run-record, run end validates its own argv first, and QA auto-end runs
|
|
12764
|
+
// after QA persistence. Keep the scope at this boundary so a future
|
|
12765
|
+
// closeout refusal swallowed by onError cannot mark the outer invocation.
|
|
12766
|
+
const summary = await runWithRefusalScope(() => runRecordCommand(endArgs, found, { silent, promptForConsent }));
|
|
12767
|
+
// Clearing the session is a write like any other, so a closer carrying
|
|
12768
|
+
// --dry-run leaves it open: the operator sees the record the close would
|
|
12769
|
+
// assemble and can still close for real afterwards.
|
|
12770
|
+
if (endArgs["dry-run"] !== true) clearRunSession(found.path);
|
|
12023
12771
|
return summary;
|
|
12024
12772
|
} catch (error) {
|
|
12025
|
-
|
|
12026
|
-
|
|
12773
|
+
// A nested run-record refusal is a failure of the invoking closer, which
|
|
12774
|
+
// has already read session state. Do not pass its tag to outer persistence.
|
|
12775
|
+
const failure = error?.code === REFUSED_INVOCATION ? new Error(error.message, { cause: error }) : error;
|
|
12776
|
+
if (!onError) throw failure;
|
|
12777
|
+
onError(failure);
|
|
12027
12778
|
return null;
|
|
12028
12779
|
}
|
|
12029
12780
|
}
|
|
@@ -12041,11 +12792,34 @@ async function closeRunSession(found, { packet, extraArgs = {}, silent = false,
|
|
|
12041
12792
|
// forms. `run status` never sweeps — it is read-only.
|
|
12042
12793
|
// Best-effort throughout: a closeout failure clears the file and says so on
|
|
12043
12794
|
// stderr; it never blocks the command that triggered it.
|
|
12795
|
+
//
|
|
12796
|
+
// The sweep is an effect of the command that triggers it, and it runs BEFORE
|
|
12797
|
+
// dispatch — so it happens even when the argv that follows is refused. That is
|
|
12798
|
+
// deliberate (the stale session at the target is closed out either way), but it
|
|
12799
|
+
// makes the sweep the one place where `--no-write` could still write: it
|
|
12800
|
+
// assembles a Run Record and removes the session file. `--no-write` writes
|
|
12801
|
+
// nothing, the closeout included; see the guard below.
|
|
12044
12802
|
const STALE_SWEEP_TARGET_COMMANDS = new Set(["start", "prepare-build", "build"]);
|
|
12045
12803
|
|
|
12046
12804
|
async function closeOutStaleRunSessions(command, args) {
|
|
12047
12805
|
// A command that opted out of sessions altogether must not sweep either.
|
|
12048
12806
|
if (args["no-run-session"] === true) return [];
|
|
12807
|
+
// --no-write leaves the tree byte-identical. Inheriting the flag into the
|
|
12808
|
+
// closeout was not enough: it suppressed the Run Record but clearRunSession
|
|
12809
|
+
// still deleted the session file, so `--no-write` moved bytes. Skip the
|
|
12810
|
+
// sweep entirely instead — the stale session stays for the next run that
|
|
12811
|
+
// does write.
|
|
12812
|
+
if (args["no-write"] === true) return [];
|
|
12813
|
+
// Nor may a dry run sweep. The closeout writes a Run Record, deletes the
|
|
12814
|
+
// session file and (under consent) sends a remit — every effect --dry-run
|
|
12815
|
+
// promises not to have. The sweep runs BEFORE dispatch, so it was doing all
|
|
12816
|
+
// three for commands that implement the flag: `run end --dry-run --json`
|
|
12817
|
+
// over an aged session exited 0, wrote a record, POSTed once and removed the
|
|
12818
|
+
// session. --dry-run means "show me, do nothing"; the stale session simply
|
|
12819
|
+
// stays stale until a real invocation closes it. Gated on the same predicate
|
|
12820
|
+
// persistLifecycleIfRequested uses, so a stray --dry-run on a command that
|
|
12821
|
+
// does not implement it changes nothing here either.
|
|
12822
|
+
if (args["dry-run"] === true && commandImplementsDryRun(command, args)) return [];
|
|
12049
12823
|
const roots = [];
|
|
12050
12824
|
if (STALE_SWEEP_TARGET_COMMANDS.has(command) && optionalString(args.target)) roots.push(resolve(args.target));
|
|
12051
12825
|
if (command === "run" && (args._[1] === "start" || args._[1] === "end")) {
|
|
@@ -12062,8 +12836,11 @@ async function closeOutStaleRunSessions(command, args) {
|
|
|
12062
12836
|
}
|
|
12063
12837
|
}
|
|
12064
12838
|
// The closeout inherits the invoking command's remit controls: an explicit
|
|
12065
|
-
// --no-remit
|
|
12066
|
-
//
|
|
12839
|
+
// --no-remit stays an opt-out, and a run pointed at a custom --proxy-base
|
|
12840
|
+
// never remits the stale record to the canonical endpoint. --no-write is
|
|
12841
|
+
// carried too, though the guard above means it never arrives true: if the
|
|
12842
|
+
// sweep ever becomes conditional rather than skipped, the closeout must
|
|
12843
|
+
// still see it.
|
|
12067
12844
|
const inherited = {};
|
|
12068
12845
|
for (const flag of ["no-remit", "no-write", "proxy-base"]) {
|
|
12069
12846
|
if (args[flag] !== undefined) inherited[flag] = args[flag];
|
|
@@ -12207,6 +12984,14 @@ export function describeCampaignKeyRejection(rejected) {
|
|
|
12207
12984
|
// docs/workflow-findings-sidecar.md.
|
|
12208
12985
|
async function runRecordCommand(args, ambient = null, { silent = false, promptForConsent = true } = {}) {
|
|
12209
12986
|
const packetPath = resolve(requireArg(args, "packet"));
|
|
12987
|
+
if (args["new-run"] === true && optionalString(args["run-id"])) {
|
|
12988
|
+
throw refused("run-record: --new-run and --run-id are exclusive; --run-id names the run to re-emit, --new-run mints a fresh one.");
|
|
12989
|
+
}
|
|
12990
|
+
const agentUsage = refusing(() => parseAgentUsageArgs(args));
|
|
12991
|
+
// --dry-run assembles the record and shows it, then writes and sends
|
|
12992
|
+
// nothing. It differs from --no-write, which skips the assembly's reads
|
|
12993
|
+
// as well; combining the two is allowed and still writes nothing.
|
|
12994
|
+
const dryRun = isDryRun(args);
|
|
12210
12995
|
const parsedSurfaces = parseRunRecordSurfaces(args.surfaces);
|
|
12211
12996
|
const packet = readJson(packetPath);
|
|
12212
12997
|
const explicitTargetRepo = resolveFromFile(packetPath, packet.assembly?.target_repo);
|
|
@@ -12230,10 +13015,12 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
|
|
|
12230
13015
|
: inferQaVerdictPath({ packet, report, reportPath: reportExists ? reportPath : null, targetRepo, baseDir });
|
|
12231
13016
|
const qaVerdictExists = qaVerdictPath != null && existsSync(qaVerdictPath);
|
|
12232
13017
|
const qaVerdict = qaVerdictExists ? readJson(qaVerdictPath) : null;
|
|
13018
|
+
if (qaVerdict && (packet.spec?.local_spec_id != null || qaVerdict.local_spec_id != null) && !qaVerdictIdentityMatch(qaVerdict, packet)) {
|
|
13019
|
+
throw new Error("run-record: QA verdict does not match this packet's local_spec_id; foreign evidence cannot be recorded as this local campaign.");
|
|
13020
|
+
}
|
|
12233
13021
|
|
|
12234
13022
|
const journalPath = resolveJournalPath(args);
|
|
12235
13023
|
const journal = readJournal(journalPath);
|
|
12236
|
-
const agentUsage = parseAgentUsageArgs(args);
|
|
12237
13024
|
|
|
12238
13025
|
// run_id: explicit flag > --new-run (mint) > active run session > the most
|
|
12239
13026
|
// recent Run Record on disk for this packet's campaign > freshly minted.
|
|
@@ -12246,9 +13033,6 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
|
|
|
12246
13033
|
// record for this campaign exists, or when --new-run asks for it. The
|
|
12247
13034
|
// source travels on the stdout envelope only: the record's schema is
|
|
12248
13035
|
// hashed surface and does not carry it.
|
|
12249
|
-
if (args["new-run"] === true && optionalString(args["run-id"])) {
|
|
12250
|
-
throw new Error("run-record: --new-run and --run-id are exclusive; --run-id names the run to re-emit, --new-run mints a fresh one.");
|
|
12251
|
-
}
|
|
12252
13036
|
const listOnly = args.list === true;
|
|
12253
13037
|
// The directory is scanned only when something reads it: --list, or an id
|
|
12254
13038
|
// that nothing else names. An explicit --run-id, --new-run or an open
|
|
@@ -12307,6 +13091,7 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
|
|
|
12307
13091
|
run_id_source: runIdSource,
|
|
12308
13092
|
records,
|
|
12309
13093
|
remit: { result: null, http_status: null, base_kind: null, sent: false, preserved: false },
|
|
13094
|
+
...(dryRun ? { dry_run: true, would_write: null, would_remit: null } : {}),
|
|
12310
13095
|
};
|
|
12311
13096
|
if (silent) return summary;
|
|
12312
13097
|
if (args.json) {
|
|
@@ -12373,7 +13158,11 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
|
|
|
12373
13158
|
}
|
|
12374
13159
|
if (existsSync(journalPath)) artifacts.push(runRecordArtifactRef("findings_journal", journalPath, WORKFLOW_FINDING_SCHEMA, baseDir));
|
|
12375
13160
|
|
|
12376
|
-
|
|
13161
|
+
// What a real run of this command line would write, and what this one does:
|
|
13162
|
+
// a dry run assembles and validates the record a real run would write
|
|
13163
|
+
// (assertRunRecordValid), and stops at the write itself (writeRunRecord).
|
|
13164
|
+
const wouldWrite = args["no-write"] !== true;
|
|
13165
|
+
const write = wouldWrite && !dryRun;
|
|
12377
13166
|
// The verdict publish outcome this record carries: the session's attempt
|
|
12378
13167
|
// for the verdict being recorded (the auto-end and `run end` both close
|
|
12379
13168
|
// through here with the session still ambient), else the newest attempt
|
|
@@ -12388,8 +13177,10 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
|
|
|
12388
13177
|
// refuses a second send, so a record it already has is final: it is neither
|
|
12389
13178
|
// re-sent nor rewritten here. A prior send that did not land (failed,
|
|
12390
13179
|
// pending) is retried when this run may send, and kept as it stands when it
|
|
12391
|
-
// may not. A
|
|
12392
|
-
|
|
13180
|
+
// may not. A pure --no-write run reads nothing: it writes and sends nothing.
|
|
13181
|
+
// A --dry-run does read it, because what a real run would do here is the
|
|
13182
|
+
// very thing the dry run is being asked to report.
|
|
13183
|
+
const prior = write || dryRun ? readPriorRunRecord(runId, baseDir) : null;
|
|
12393
13184
|
const priorRemit = priorRemitOutcome(prior?.record);
|
|
12394
13185
|
const storedRemotely = priorRemit?.state === "ok";
|
|
12395
13186
|
// A pure local-inspection run (--no-write), an explicit --no-remit, or a
|
|
@@ -12410,6 +13201,16 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
|
|
|
12410
13201
|
announceDefaultOnTelemetry(consent.scope || proxyBase);
|
|
12411
13202
|
}
|
|
12412
13203
|
|
|
13204
|
+
// Under --dry-run the remit is disabled by construction (write is false), so
|
|
13205
|
+
// `remitDisabled` cannot say whether a real run would have sent. This does:
|
|
13206
|
+
// the same three stoppers minus the dry run itself, against the consent this
|
|
13207
|
+
// machine actually resolved.
|
|
13208
|
+
const wouldRemit = dryRun
|
|
13209
|
+
&& args["no-remit"] !== true
|
|
13210
|
+
&& args["no-write"] !== true
|
|
13211
|
+
&& !storedRemotely
|
|
13212
|
+
&& consent.state === "on";
|
|
13213
|
+
|
|
12413
13214
|
// A publish the record already says landed is never downgraded by a
|
|
12414
13215
|
// reassembly: the prior ok block wins over a session attempt that did not
|
|
12415
13216
|
// land, mirroring the remit carry-forward below.
|
|
@@ -12428,6 +13229,7 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
|
|
|
12428
13229
|
consent: { state: consent.state, source: consent.source },
|
|
12429
13230
|
identity: {
|
|
12430
13231
|
map_id: optionalString(packet.spec?.map_id),
|
|
13232
|
+
...localSpecIdentityFields(packet.spec),
|
|
12431
13233
|
campaign_slug: optionalString(packet.campaign?.public_route_slug),
|
|
12432
13234
|
template_family: optionalString(packet.assembly?.template_family),
|
|
12433
13235
|
entry_point_shape: "packet",
|
|
@@ -12446,9 +13248,6 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
|
|
|
12446
13248
|
agentUsage,
|
|
12447
13249
|
});
|
|
12448
13250
|
|
|
12449
|
-
// Write before any network call so local capture does not depend on telemetry
|
|
12450
|
-
// being fast or reachable. If a crash lands before the final rewrite below,
|
|
12451
|
-
// the durable record is explicitly pending instead of silently skipped.
|
|
12452
13251
|
const shouldAttemptRemit = !remitDisabled && consent.state === "on";
|
|
12453
13252
|
const remitBaseKind = describeRemitBaseKind(proxyBase);
|
|
12454
13253
|
|
|
@@ -12467,6 +13266,9 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
|
|
|
12467
13266
|
record: prior.record,
|
|
12468
13267
|
run_id_source: runIdSource,
|
|
12469
13268
|
remit: { result: REMIT_RESULTS.not_contacted, http_status: null, base_kind: null, sent: false, preserved: true },
|
|
13269
|
+
// A record the receiver already holds is final: a real run of this same
|
|
13270
|
+
// command line would write nothing and send nothing either.
|
|
13271
|
+
...(dryRun ? { dry_run: true, would_write: null, would_remit: null } : {}),
|
|
12470
13272
|
};
|
|
12471
13273
|
if (silent) return summary;
|
|
12472
13274
|
if (args.json) {
|
|
@@ -12504,7 +13306,11 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
|
|
|
12504
13306
|
record.remit_base_kind = carriedForward.base_kind;
|
|
12505
13307
|
}
|
|
12506
13308
|
|
|
12507
|
-
|
|
13309
|
+
// The record is validated whenever a real run of this command line would
|
|
13310
|
+
// write one — under --dry-run too — and always BEFORE the remit below, so an
|
|
13311
|
+
// invalid record refuses ahead of any send on both paths. The write itself is
|
|
13312
|
+
// separate and happens after the remit; validating here does not write.
|
|
13313
|
+
if (wouldWrite) assertRunRecordValid(record);
|
|
12508
13314
|
|
|
12509
13315
|
// Remit is consent-gated, non-fatal, bounded, and keyed on run_id — the
|
|
12510
13316
|
// receiver holds one record per id and refuses a second POST for one it
|
|
@@ -12514,7 +13320,10 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
|
|
|
12514
13320
|
// The key is only resolved when a send will actually be attempted: under
|
|
12515
13321
|
// consent-off or --no-remit nothing goes out, so nothing is read and nothing
|
|
12516
13322
|
// is said about a credential.
|
|
12517
|
-
|
|
13323
|
+
// A dry run resolves it too, and only when a real run would have: the
|
|
13324
|
+
// destination gate below names the credential that would travel, and the
|
|
13325
|
+
// preview must name the one the real send would.
|
|
13326
|
+
const keySource = shouldAttemptRemit || wouldRemit ? resolveCampaignsApiKeySource(packet, packetPath, process.env) : { key: null, rejected: null };
|
|
12518
13327
|
const campaignKey = keySource.key;
|
|
12519
13328
|
// A refused key is not a missing key. Say so on stderr, naming the source
|
|
12520
13329
|
// and not the value, so the operator fixes the credential instead of reading
|
|
@@ -12524,7 +13333,30 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
|
|
|
12524
13333
|
// send's outcome, which is only known below; the credential itself never
|
|
12525
13334
|
// leaves the machine.
|
|
12526
13335
|
const keyRejection = describeCampaignKeyRejection(keySource.rejected);
|
|
12527
|
-
if (keyRejection) process.stderr.write(`[campaigns-os] run-record: ${keyRejection} This run's remit is attempted without a tenant scope
|
|
13336
|
+
if (keyRejection) process.stderr.write(`[campaigns-os] run-record: ${keyRejection} ${dryRun ? "A real run's remit would be attempted without a tenant scope." : "This run's remit is attempted without a tenant scope."}\n`);
|
|
13337
|
+
|
|
13338
|
+
// The transport's own preconditions, run for a dry run as well: `remit`
|
|
13339
|
+
// demands a fetch to send with, and refuses a base that is not https (nor a
|
|
13340
|
+
// loopback host), BEFORE it opens a socket — so a send the real run could
|
|
13341
|
+
// never have made must not be previewed as one it would post. Same
|
|
13342
|
+
// functions, in the transport's order, with the same label and the same
|
|
13343
|
+
// credential wording the remit below hands them. The remit rail is non-fatal
|
|
13344
|
+
// by contract — the real run classifies either refusal as a failed remit and
|
|
13345
|
+
// still exits 0 — so the dry run reports it on the envelope and exits 0 too.
|
|
13346
|
+
let wouldRemitEndpoint = null;
|
|
13347
|
+
let wouldRemitRefusal = null;
|
|
13348
|
+
if (wouldRemit) {
|
|
13349
|
+
try {
|
|
13350
|
+
assertFetchAvailable(globalThis.fetch);
|
|
13351
|
+
const { base } = assertSecureProxyBase(proxyBase, {
|
|
13352
|
+
label: "Run Telemetry remit",
|
|
13353
|
+
credential: typeof campaignKey === "string" && campaignKey.trim() ? "the campaign key" : null,
|
|
13354
|
+
});
|
|
13355
|
+
wouldRemitEndpoint = `${base}${DEFAULT_RUNS_ENDPOINT}`;
|
|
13356
|
+
} catch (error) {
|
|
13357
|
+
wouldRemitRefusal = String(error?.message ?? error);
|
|
13358
|
+
}
|
|
13359
|
+
}
|
|
12528
13360
|
const remitStatus = shouldAttemptRemit
|
|
12529
13361
|
? await remitRunRecord(record, { proxyBase, consent, campaignKey })
|
|
12530
13362
|
: { attempted: false, ok: null, error: null, endpoint: null, result: null, http_status: null };
|
|
@@ -12543,7 +13375,11 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
|
|
|
12543
13375
|
record.remit_base_kind = remitStatus.attempted ? remitBaseKind : null;
|
|
12544
13376
|
}
|
|
12545
13377
|
|
|
12546
|
-
|
|
13378
|
+
// The record's one and only write, after the remit so the file that lands
|
|
13379
|
+
// carries this run's remit_* outcome rather than the placeholders it was
|
|
13380
|
+
// assembled with. Validated above, ahead of the send; nothing is written
|
|
13381
|
+
// under --dry-run or --no-write.
|
|
13382
|
+
const recordPath = write ? writeRunRecord(record, { baseDir }) : null;
|
|
12547
13383
|
|
|
12548
13384
|
const summary = {
|
|
12549
13385
|
ok: true,
|
|
@@ -12569,6 +13405,13 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
|
|
|
12569
13405
|
sent: remitStatus.attempted,
|
|
12570
13406
|
preserved: Boolean(carriedForward),
|
|
12571
13407
|
},
|
|
13408
|
+
// What a real run of this same command line — the one without --dry-run —
|
|
13409
|
+
// would have done: the record file it would write, and the endpoint it
|
|
13410
|
+
// would POST to (null when --no-remit, --no-write or consent would have
|
|
13411
|
+
// stopped the send anyway, and null with `would_remit_refused` set when the
|
|
13412
|
+
// transport's destination gate refuses the base before any socket opens).
|
|
13413
|
+
// Envelope only; never on the record.
|
|
13414
|
+
...(dryRun ? { dry_run: true, would_write: resolveRunRecordPath(runId, baseDir), would_remit: wouldRemitEndpoint, would_remit_refused: wouldRemitRefusal } : {}),
|
|
12572
13415
|
};
|
|
12573
13416
|
if (silent) return summary;
|
|
12574
13417
|
if (args.json) {
|
|
@@ -12589,10 +13432,33 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
|
|
|
12589
13432
|
console.log(`Remit: skipped (consent ${record.consent_state}${remitDisabled ? ", disabled for this run" : ""}).`);
|
|
12590
13433
|
}
|
|
12591
13434
|
if (write) console.log(`Wrote: ${recordPath}`);
|
|
12592
|
-
else
|
|
13435
|
+
else if (dryRun) {
|
|
13436
|
+
console.log("Dry run (--dry-run). Nothing written, nothing sent.");
|
|
13437
|
+
console.log(`Would write: ${summary.would_write}`);
|
|
13438
|
+
console.log(`Would remit: ${summary.would_remit || (summary.would_remit_refused ? `nothing — the destination is refused before any request: ${summary.would_remit_refused}` : "nothing (remit is off for this run)")}`);
|
|
13439
|
+
} else console.log("Dry run only (--no-write). No record written, no remit.");
|
|
12593
13440
|
return summary;
|
|
12594
13441
|
}
|
|
12595
13442
|
|
|
13443
|
+
/**
|
|
13444
|
+
* The Run Record's refusal, lifted out of the write. `writeRunRecord` validates
|
|
13445
|
+
* the record before it writes and refuses an invalid one; that refusal belongs
|
|
13446
|
+
* to the command rather than to the write, because it has to fire on the dry
|
|
13447
|
+
* path (which never writes) and ahead of the remit on the real one (which
|
|
13448
|
+
* writes only afterwards). Both modes therefore refuse the same record with the
|
|
13449
|
+
* same message and the same exit code, before any send: the check fires here,
|
|
13450
|
+
* never on the identical one inside run-record.mjs (which stays as the writer's
|
|
13451
|
+
* own last guard). The validator is that writer's — imported, not re-stated —
|
|
13452
|
+
* so the two cannot disagree about what a valid record is. Writes nothing.
|
|
13453
|
+
*/
|
|
13454
|
+
function assertRunRecordValid(record) {
|
|
13455
|
+
const validation = validateRunRecord(record);
|
|
13456
|
+
if (!validation.ok) {
|
|
13457
|
+
const detail = validation.errors.map((error) => `[${error.code}] ${error.message}`).join("; ");
|
|
13458
|
+
throw new Error(`Run Record failed validation; refusing to write: ${detail}`);
|
|
13459
|
+
}
|
|
13460
|
+
}
|
|
13461
|
+
|
|
12596
13462
|
// The record already written under `runId` for this target, or null when there
|
|
12597
13463
|
// is none, it cannot be parsed, or it is not a valid Run Record. Only a record
|
|
12598
13464
|
// `writeRunRecord` could have written is trusted as a prior — the same
|
|
@@ -12697,7 +13563,8 @@ function inferQaVerdictPath({ packet, report, reportPath = null, targetRepo = nu
|
|
|
12697
13563
|
report,
|
|
12698
13564
|
reportPath: reportPath || (targetRepo ? campaignSidecarPaths(targetRepo).reportPath : null),
|
|
12699
13565
|
roots: [targetRepo, baseDir],
|
|
12700
|
-
}).filter((candidate) => candidate.verdict && candidate.trusted
|
|
13566
|
+
}).filter((candidate) => candidate.verdict && candidate.trusted
|
|
13567
|
+
&& ((packet?.spec?.local_spec_id == null && candidate.verdict.local_spec_id == null) || candidate.identityMatch));
|
|
12701
13568
|
eligible.sort((a, b) => {
|
|
12702
13569
|
const scoreDelta = qaVerdictCandidateScore(b, packet) - qaVerdictCandidateScore(a, packet);
|
|
12703
13570
|
if (scoreDelta !== 0) return scoreDelta;
|
|
@@ -12733,11 +13600,31 @@ function parseRunRecordSurfaces(value) {
|
|
|
12733
13600
|
const surfaces = parseCommaList(value);
|
|
12734
13601
|
const unknown = surfaces.filter((surface) => !RUN_RECORD_SURFACES.includes(surface));
|
|
12735
13602
|
if (unknown.length) {
|
|
12736
|
-
throw
|
|
13603
|
+
throw refused(`Unknown --surfaces value(s): ${unknown.join(", ")}. Use one of: ${RUN_RECORD_SURFACES.join(", ")}.`);
|
|
12737
13604
|
}
|
|
12738
13605
|
return surfaces;
|
|
12739
13606
|
}
|
|
12740
13607
|
|
|
13608
|
+
function validateRunRecordArgv(args) {
|
|
13609
|
+
for (const flag of RUN_RECORD_INHERITABLE_FLAGS) {
|
|
13610
|
+
// The integer flags are checked below by parseAgentUsageArgs, which keeps
|
|
13611
|
+
// their non-negative-integer diagnostics for bare and blank values.
|
|
13612
|
+
if (!RUN_RECORD_BOOLEAN_INHERITABLE_FLAGS.has(flag) && !RUN_RECORD_INTEGER_INHERITABLE_FLAGS.has(flag) && Object.hasOwn(args, flag)) requireArg(args, flag);
|
|
13613
|
+
}
|
|
13614
|
+
if (Object.hasOwn(args, "run-id")) requireArg(args, "run-id");
|
|
13615
|
+
if (Object.hasOwn(args, "new-run") && args["new-run"] !== true) {
|
|
13616
|
+
throw refused("--new-run takes no value.");
|
|
13617
|
+
}
|
|
13618
|
+
if (args["new-run"] === true && optionalString(args["run-id"])) {
|
|
13619
|
+
throw refused("run-record: --new-run and --run-id are exclusive; --run-id names the run to re-emit, --new-run mints a fresh one.");
|
|
13620
|
+
}
|
|
13621
|
+
return {
|
|
13622
|
+
agentUsage: refusing(() => parseAgentUsageArgs(args)),
|
|
13623
|
+
dryRun: isDryRun(args),
|
|
13624
|
+
parsedSurfaces: parseRunRecordSurfaces(args.surfaces),
|
|
13625
|
+
};
|
|
13626
|
+
}
|
|
13627
|
+
|
|
12741
13628
|
function parseAgentUsageArgs(args) {
|
|
12742
13629
|
const fields = {
|
|
12743
13630
|
"agent-input-tokens": "input_tokens",
|
|
@@ -12823,6 +13710,21 @@ function toolkitProvenance({ silent = false } = {}) {
|
|
|
12823
13710
|
// canonical endpoint, or against --proxy-base when given, so it reports what
|
|
12824
13711
|
// a remit to that endpoint would do. `off` takes no --proxy-base: an OFF
|
|
12825
13712
|
// choice is machine-wide and the record it writes carries no scope.
|
|
13713
|
+
// `assertSecureProxyBase` is SHARED with the remit rail, where it runs in the
|
|
13714
|
+
// middle of a handler: `remit()` reaches it after the Run Record has been built
|
|
13715
|
+
// and written, and `spec derive --write-map` after the derive produced one. A
|
|
13716
|
+
// throw from those positions is a handler failure — the most valuable lifecycle
|
|
13717
|
+
// entry there is — so the refusal tag cannot live inside the validator.
|
|
13718
|
+
//
|
|
13719
|
+
// The call sites below are the up-front ones: the base comes straight off argv
|
|
13720
|
+
// and is checked before any configuration read or write, before a credential is
|
|
13721
|
+
// attached, and before a request. The tag therefore goes HERE, where the
|
|
13722
|
+
// position is known — `refusing()` is the shared form of that contract. The
|
|
13723
|
+
// message and the exit code stay the validator's own; only the verdict the
|
|
13724
|
+
// lifecycle journal reads is added.
|
|
13725
|
+
const refuseInsecureProxyBase = (proxyBase, options) =>
|
|
13726
|
+
refusing(() => assertSecureProxyBase(proxyBase, options));
|
|
13727
|
+
|
|
12826
13728
|
async function telemetryCommand(args) {
|
|
12827
13729
|
const sub = args._[1] || "status";
|
|
12828
13730
|
const configPath = resolveConfigPath();
|
|
@@ -12831,17 +13733,17 @@ async function telemetryCommand(args) {
|
|
|
12831
13733
|
// empty variable) is not "no flag": treating it as absent would grant or
|
|
12832
13734
|
// check the canonical endpoint under a request that named something else.
|
|
12833
13735
|
if (Object.hasOwn(args, "proxy-base") && !requestedBase) {
|
|
12834
|
-
throw
|
|
13736
|
+
throw refused(`telemetry ${sub}: --proxy-base needs a URL (https, or a loopback host); nothing was written.`);
|
|
12835
13737
|
}
|
|
12836
13738
|
// Same transport rule as the remit rail: https, or a loopback host. A grant
|
|
12837
13739
|
// for a base a remit would refuse to send to is not a grant, and a status
|
|
12838
13740
|
// check against one would report on a remit that can never happen. Nothing
|
|
12839
13741
|
// is sent here, so the in-clear warning is left to the remit.
|
|
12840
|
-
const secureBase = () =>
|
|
13742
|
+
const secureBase = () => refuseInsecureProxyBase(requestedBase, { label: `telemetry ${sub}`, warn: () => {} }).base;
|
|
12841
13743
|
|
|
12842
13744
|
if (sub === "on" || sub === "off") {
|
|
12843
13745
|
if (sub === "off" && requestedBase) {
|
|
12844
|
-
throw
|
|
13746
|
+
throw refused(`telemetry off: --proxy-base is not accepted; turning telemetry off applies to every endpoint. To grant one endpoint instead, run: ${scopedConsentCommand(requestedBase)}`);
|
|
12845
13747
|
}
|
|
12846
13748
|
const proxyBase = requestedBase ? secureBase() : DEFAULT_PROXY_BASE;
|
|
12847
13749
|
const { configPath: written, config } = writeConsentConfig(sub, { configPath, proxyBase, source: "telemetry-command" });
|
|
@@ -12917,7 +13819,7 @@ async function telemetryCommand(args) {
|
|
|
12917
13819
|
|
|
12918
13820
|
if (sub === "list") return telemetryList(args);
|
|
12919
13821
|
|
|
12920
|
-
throw
|
|
13822
|
+
throw refused(`Unknown telemetry subcommand "${sub}". Use: status | on | off | list.`);
|
|
12921
13823
|
}
|
|
12922
13824
|
|
|
12923
13825
|
// `telemetry list` — the reader that never existed. Since the receiver
|
|
@@ -12932,14 +13834,16 @@ const TELEMETRY_LIST_TIMEOUT_MS = 15_000;
|
|
|
12932
13834
|
|
|
12933
13835
|
const TELEMETRY_LIST_MAX_BODY_BYTES = 4_000_000; // the receiver caps a listing at 500 summaries
|
|
12934
13836
|
|
|
12935
|
-
async function telemetryList(args, { fetchImpl = globalThis.fetch } = {}) {
|
|
12936
|
-
if (typeof fetchImpl !== "function") throw
|
|
13837
|
+
export async function telemetryList(args, { fetchImpl = globalThis.fetch } = {}) {
|
|
13838
|
+
if (typeof fetchImpl !== "function") throw refused("Global fetch is not available. Upgrade to Node 18+.");
|
|
12937
13839
|
// Same transport gate the remit rail uses: https, or a loopback host with a
|
|
12938
|
-
// loud warning that the credential is in clear. Anything else
|
|
12939
|
-
// before a credential is attached to a request
|
|
12940
|
-
//
|
|
12941
|
-
// so
|
|
12942
|
-
|
|
13840
|
+
// loud warning that the credential is in clear. Anything else is refused
|
|
13841
|
+
// here, before a credential is attached to a request and before the packet
|
|
13842
|
+
// below is read — the same up-front position the gate holds under
|
|
13843
|
+
// `telemetry status|on`, so it is tagged the same way. The gate's own
|
|
13844
|
+
// normalized base is what the consent scope and the request URL below are
|
|
13845
|
+
// built from, so one string decides both.
|
|
13846
|
+
const { url: proxyUrl, base: proxyBase, loopback } = refuseInsecureProxyBase(
|
|
12943
13847
|
optionalString(args["proxy-base"]) || DEFAULT_PROXY_BASE,
|
|
12944
13848
|
{ label: "telemetry list", credential: "the listing credential (the ops admin key, or the packet's campaign key)" },
|
|
12945
13849
|
);
|
|
@@ -12951,9 +13855,11 @@ async function telemetryList(args, { fetchImpl = globalThis.fetch } = {}) {
|
|
|
12951
13855
|
const { key, rejected } = resolveCampaignsApiKeySource(packet, packetPath, process.env);
|
|
12952
13856
|
const rejection = describeCampaignKeyRejection(rejected);
|
|
12953
13857
|
// Fail fast, before a request: a refused credential names its source so
|
|
12954
|
-
// the operator can fix it, and nothing is sent in the meantime.
|
|
12955
|
-
|
|
12956
|
-
|
|
13858
|
+
// the operator can fix it, and nothing is sent in the meantime. Every
|
|
13859
|
+
// refusal ahead of the request is tagged, so the lifecycle journal records
|
|
13860
|
+
// nothing for it — the same rule as a flag refused up front.
|
|
13861
|
+
if (rejection) throw refused(`telemetry list --packet: ${rejection}`);
|
|
13862
|
+
if (!key) throw refused(`telemetry list --packet: no Campaigns API key found in ${packetPath}, its local CampaignSpec, or the declared env source; pass a packet that carries one, or list cross-tenant with the admin key instead.`);
|
|
12957
13863
|
headers["X-Campaign-Key"] = key;
|
|
12958
13864
|
scope = "tenant";
|
|
12959
13865
|
} else {
|
|
@@ -12963,11 +13869,12 @@ async function telemetryList(args, { fetchImpl = globalThis.fetch } = {}) {
|
|
|
12963
13869
|
// posture remit takes with default-on consent.
|
|
12964
13870
|
const canonical = normalizeConsentScope(proxyBase) === CANONICAL_REMIT_SCOPE;
|
|
12965
13871
|
if (!canonical && !loopback && args["trust-proxy-base"] !== true) {
|
|
12966
|
-
throw
|
|
13872
|
+
throw refused(`telemetry list: refusing to send the ops admin key to non-canonical ${proxyUrl.origin}. Pass --trust-proxy-base if that endpoint is yours, or use --packet for a tenant-scoped listing.`);
|
|
12967
13873
|
}
|
|
12968
13874
|
const envName = optionalString(args["admin-key-env"]) || DEFAULT_ADMIN_KEY_ENV;
|
|
12969
13875
|
const adminKey = process.env[envName];
|
|
12970
|
-
if (!isNonEmptyString(adminKey)) throw
|
|
13876
|
+
if (!isNonEmptyString(adminKey)) throw refused(`telemetry list: set ${envName} (the ops admin key) for the cross-tenant listing, or pass --packet <campaign-runtime.build.json> for a tenant-scoped one.`);
|
|
13877
|
+
console.warn("Warning: CAMPAIGN_OPS_ADMIN_KEY (or the selected admin-key env) is a break-glass /api/runs listing credential. Use campaigns-os login for supported store-profile reads; login does not grant cross-tenant run listing.");
|
|
12971
13878
|
headers["X-Campaigns-Ops-Admin-Key"] = adminKey.trim();
|
|
12972
13879
|
scope = "admin";
|
|
12973
13880
|
}
|
|
@@ -13291,7 +14198,8 @@ export function resultTextLines(result, { headerLines = [] } = {}) {
|
|
|
13291
14198
|
// A waive command's second line names what it recorded; the third is the
|
|
13292
14199
|
// stage doctor now picks for the report the waiver was written to.
|
|
13293
14200
|
if (WAIVE_ACTIONS.has(result.action) && result.gate) {
|
|
13294
|
-
lines.push(
|
|
14201
|
+
lines.push(`${result.dry_run ? "Would waive" : "Waived"}: ${result.gate} by ${result.waiver?.waived_by || "(unattributed)"}${result.waiver?.expires_at ? ` until ${result.waiver.expires_at}` : ""}`);
|
|
14202
|
+
if (result.dry_run) lines.push(`Would write: ${result.would_write} (nothing was written)`);
|
|
13295
14203
|
if (result.next_stage) lines.push(`Next stage: ${result.next_stage}${result.next_stage_reason ? ` (${result.next_stage_reason})` : ""}`);
|
|
13296
14204
|
}
|
|
13297
14205
|
if (result.targets?.length) {
|
|
@@ -13386,13 +14294,13 @@ export async function specDeriveWithMapWriteback(args, { fetchImpl = undefined,
|
|
|
13386
14294
|
// by a stray value. `--proxy-base` names a URL or is refused here, before
|
|
13387
14295
|
// anything is read, as `telemetry` refuses a bare one.
|
|
13388
14296
|
if (Object.hasOwn(args, "write-map") && args["write-map"] !== true) {
|
|
13389
|
-
throw
|
|
14297
|
+
throw refused(`--write-map takes no value (got ${JSON.stringify(args["write-map"])}); write \`--write-map\` on its own, after the other flags.`);
|
|
13390
14298
|
}
|
|
13391
14299
|
if (Object.hasOwn(args, "proxy-base") && !optionalString(args["proxy-base"])) {
|
|
13392
|
-
throw
|
|
14300
|
+
throw refused("spec derive: --proxy-base needs a URL (https, or a loopback host); nothing was written.");
|
|
13393
14301
|
}
|
|
13394
14302
|
if (optionalString(args["proxy-base"]) && args["write-map"] !== true) {
|
|
13395
|
-
throw
|
|
14303
|
+
throw refused("spec derive: --proxy-base only applies with --write-map; nothing was written.");
|
|
13396
14304
|
}
|
|
13397
14305
|
const writeMap = args["write-map"] === true;
|
|
13398
14306
|
const localArgs = Object.fromEntries(Object.entries(args).filter(([key]) => !SPEC_DERIVE_MAP_FLAGS.includes(key)));
|