@nextcommerce/campaigns-os 1.37.3 → 1.41.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +114 -10
- package/CHANGELOG.md +530 -0
- package/README.md +38 -27
- package/contracts/agent-relevant-change-policy.v1.json +11 -1
- package/contracts/effects.v1.json +4794 -0
- package/contracts/release-ledger.json +789 -0
- package/contracts/supported-surface.json +25 -5
- package/docs/build-packet.md +27 -16
- package/docs/demo-preview.md +1 -1
- package/docs/diagnostics.md +7 -4
- package/docs/effects.md +281 -0
- package/docs/gateway-login.md +113 -0
- package/docs/orientation-contract-reference.md +4 -1
- package/docs/progress-snapshots.md +3 -3
- package/docs/qa-and-test-orders.md +3 -3
- 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 +8 -3
- package/schemas/campaign-runtime-build-packet.v0.schema.json +5 -0
- package/schemas/campaigns-os-effects.v1.schema.json +211 -0
- package/schemas/campaigns-os-readback.v2.schema.json +267 -0
- package/skills/campaign-lifecycle-orientation/SKILL.md +174 -0
- package/skills/campaign-readback-classification/SKILL.md +230 -0
- package/skills/campaign-run-evidence/SKILL.md +140 -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 +45 -21
- 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 +48 -24
- package/skills.json +39 -6
- package/src/admin-transport.mjs +123 -0
- package/src/cli.mjs +991 -200
- package/src/credential-store.mjs +183 -0
- package/src/deviation.mjs +3 -2
- package/src/diagnostic.mjs +4 -1
- package/src/gate-actions.mjs +2 -2
- package/src/install-mode.mjs +17 -9
- package/src/lifecycle.mjs +95 -0
- package/src/login.mjs +152 -0
- package/src/package-install-fixture.mjs +3 -2
- package/src/qa-node.mjs +56 -19
- package/src/qa-publish.mjs +108 -2
- package/src/readback.mjs +1936 -0
- package/src/remit.mjs +17 -3
package/src/cli.mjs
CHANGED
|
@@ -29,6 +29,9 @@ import { describeSdkIgnoredMetaTags, isSdkIgnoredMetaTag } from "./sdk-meta-tags
|
|
|
29
29
|
import { HIDDEN_EAGER_MEDIA_ACTIONS, requiredActionText, substitutePacket } from "./gate-actions.mjs";
|
|
30
30
|
import { ORDER_PATH_DEPTH_DRIFT_CODE, orderPathDepthDriftText, orderPathDepthReconcileAction, orderPathDepthsDisagree, parseOrderPathDepthFlag } from "./proof-policy.mjs";
|
|
31
31
|
import { specMaterialHash } from "./spec-identity.mjs";
|
|
32
|
+
// The same predicate stage-ledger.mjs judges a mutator's result with, imported
|
|
33
|
+
// rather than re-stated so the waive preview and the commit agree by identity.
|
|
34
|
+
import { isPlainObject } from "./repo-scan.mjs";
|
|
32
35
|
import { anyAssemblyReportStageBlocked, applyDerivedAssemblyReportSummary, commitAssemblyReport, QA_GATE_PLACEHOLDER_TEXT_RESIDUE, qaGatePassedForCurrentBuild, recordProducerStageOutcome } from "./stage-ledger.mjs";
|
|
33
36
|
import { SESSION_ENDING_DISPOSITIONS, summarizePlaceholderTextGate, summarizePurchaseProof } from "./qa-verdict.mjs";
|
|
34
37
|
import { assessRunRecordCloseout, identityMatches, latestMatchingRunRecord, reasonIsRemitRecovery } from "./run-record-closeout.mjs";
|
|
@@ -96,13 +99,18 @@ import { canonicalPath, sameFile } from "./fs-identity.mjs";
|
|
|
96
99
|
import { DEFAULT_PROXY_BASE, fetchSpecByMapId } from "./spec-fetch.mjs";
|
|
97
100
|
import { writeMapSdkPin } from "./map-pin-writeback.mjs";
|
|
98
101
|
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 { assertFetchAvailable, assertSecureProxyBase, boundedResponseText, DEFAULT_RUNS_ENDPOINT, describeRemitBaseKind, isLoopbackHostname, REMIT_RESULTS, remitRunRecord } from "./remit.mjs";
|
|
100
103
|
import {
|
|
101
104
|
aggregateLifecycleForRun,
|
|
102
105
|
appendLifecycleEntry,
|
|
103
106
|
LIFECYCLE_JOURNAL_REL_PATH,
|
|
104
107
|
NOOP_RECORDER,
|
|
105
108
|
readLifecycleJournal,
|
|
109
|
+
REFUSED_INVOCATION,
|
|
110
|
+
refusalSeen,
|
|
111
|
+
refused,
|
|
112
|
+
refusing,
|
|
113
|
+
runWithRefusalScope,
|
|
106
114
|
withCommandLifecycle,
|
|
107
115
|
} from "./lifecycle.mjs";
|
|
108
116
|
import {
|
|
@@ -139,7 +147,7 @@ import {
|
|
|
139
147
|
formatStandardizationReportMarkdown,
|
|
140
148
|
} from "./standardization-report.mjs";
|
|
141
149
|
import { singleLineDetail, singleLineField } from "./text-safety.mjs";
|
|
142
|
-
import { derivePackagePin, invocationPrefixFor, localInstallStatus, resolveInvocation } from "./install-mode.mjs";
|
|
150
|
+
import { derivePackagePin, invocationPrefixFor, LOCAL_INVOCATION_PREFIX, localInstallStatus, resolveInvocation } from "./install-mode.mjs";
|
|
143
151
|
import {
|
|
144
152
|
campaignRouteRoot,
|
|
145
153
|
isAbsoluteHttpUrl,
|
|
@@ -292,7 +300,6 @@ import {
|
|
|
292
300
|
} from "./spec-derive.mjs";
|
|
293
301
|
import {
|
|
294
302
|
adminApiBaseForStore,
|
|
295
|
-
defaultStoreTokenEnvVar,
|
|
296
303
|
normalizeStoreSubdomain,
|
|
297
304
|
parseStoreTokenSource,
|
|
298
305
|
planStoreProfileDerive,
|
|
@@ -316,10 +323,11 @@ export { derivePackagePin, localInstallStatus };
|
|
|
316
323
|
|
|
317
324
|
// Every command this CLI PRODUCES for an operator or agent to copy is spelled
|
|
318
325
|
// 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
|
|
326
|
+
// `campaigns-os` from a checkout, `npx --no-install campaigns-os` from a
|
|
327
|
+
// campaign folder that pins the toolkit, `npx --yes <spec>` from an npx cache.
|
|
328
|
+
// Result payloads are never rewritten after the fact — a path, a quoted
|
|
329
|
+
// argument or a data value that happens to contain the words is left exactly
|
|
330
|
+
// as it is.
|
|
323
331
|
function cmd(verb, rest = "") {
|
|
324
332
|
const prefix = invocationPrefixFor(ROOT);
|
|
325
333
|
return `${prefix} ${verb}${rest ? ` ${rest}` : ""}`;
|
|
@@ -467,15 +475,19 @@ Usage:
|
|
|
467
475
|
campaigns-os standardize --target <campaign-repo> [--family <family>] [--slug <slug>] [--sdk-support-policy <path.json>] [--field-contract <path.json>] [--no-doctor] [--json]
|
|
468
476
|
campaigns-os theme inspect --packet <campaign-runtime.build.json> [--context <json>] [--theme-policy <inspect_only|auto|off>] [--json]
|
|
469
477
|
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
|
|
478
|
+
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
|
|
479
|
+
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
480
|
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
|
|
481
|
+
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
482
|
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
483
|
campaigns-os polish capture --packet <campaign-runtime.build.json> --base-url <url> [--report <json>] [--headed] [--auth-cookie <cookie>] [--json]
|
|
484
|
+
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.
|
|
485
|
+
campaigns-os readback --example [--json] # project the bundled synthetic sample; freshness is not computable for it by design
|
|
476
486
|
campaigns-os validate-assembly-report --report <json> [--json]
|
|
477
487
|
campaigns-os install-skills [--platform <claude|codex|agents|all>] [--target <skills-dir>] [--dry-run] [--json]
|
|
478
|
-
campaigns-os
|
|
488
|
+
campaigns-os login [--store <subdomain>]
|
|
489
|
+
campaigns-os logout [--store <subdomain>]
|
|
490
|
+
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
491
|
campaigns-os tooling diagnose [--packet <packet>] [--platform <claude|codex|agents|all>] [--json] # read-only redacted support summary
|
|
480
492
|
campaigns-os install-agent-context --target <page-kit-dir> [--dry-run]
|
|
481
493
|
campaigns-os next --packet <json> [--no-write] [--no-remit] [--proxy-base <url>] [--json] # self-decide next stage; returns gates[] + next_actions[] (exact commands) alongside the prompt
|
|
@@ -487,21 +499,24 @@ Usage:
|
|
|
487
499
|
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
500
|
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
501
|
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
|
|
502
|
+
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
503
|
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
504
|
campaigns-os findings add --stage <stage> --kind <kind> --summary <text> [--details <text>] [--packet <json>] [--journal <path>] [--run-id <id>] [...context flags]
|
|
493
505
|
campaigns-os findings harvest --packet <json> [--context <json>] [--report <json>] [--journal <path>] [--run-id <id>] [--write] [--json]
|
|
494
506
|
campaigns-os findings list [--packet <json>] [--journal <path>] [--json]
|
|
495
507
|
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]
|
|
508
|
+
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
509
|
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.
|
|
510
|
+
--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
511
|
|
|
499
|
-
|
|
512
|
+
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.
|
|
513
|
+
--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.
|
|
514
|
+
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
515
|
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
516
|
campaigns-os telemetry off [--json] # turn remit off for every endpoint (takes no --proxy-base)
|
|
502
517
|
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
518
|
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
|
|
519
|
+
campaigns-os run status [--json] # active session + incomplete stages + deviation count + exact next command; read-only — it never sweeps and never journals
|
|
505
520
|
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
521
|
|
|
507
522
|
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 +550,15 @@ Examples:
|
|
|
535
550
|
npm run campaigns-os -- standardize --target examples/target-page-kit --json
|
|
536
551
|
`;
|
|
537
552
|
|
|
553
|
+
// `refused()`, its `REFUSED_INVOCATION` tag, and the `refusalSeen()` /
|
|
554
|
+
// `runWithRefusalScope()` accessors live in lifecycle.mjs — see the contract
|
|
555
|
+
// there.
|
|
556
|
+
// Command modules raise refusals too (`qa`'s unknown subcommand) and cli.mjs
|
|
557
|
+
// imports them, so the factory has to sit below both.
|
|
558
|
+
|
|
538
559
|
// Top-level commands the CLI dispatches, used to offer a did-you-mean
|
|
539
560
|
// suggestion on a typo instead of a bare "Unknown command". Derived from the
|
|
540
|
-
// `command === "…"` literals in
|
|
561
|
+
// `command === "…"` literals in main() and dispatch() (memoized on first use) so
|
|
541
562
|
// the list cannot drift as dispatch branches are added or removed. The regex
|
|
542
563
|
// tolerates whitespace and either quote style so common reformats don't
|
|
543
564
|
// silently empty the list; a known-commands test guards against a refactor
|
|
@@ -546,7 +567,7 @@ let knownCommandsCache = null;
|
|
|
546
567
|
export function knownCommands() {
|
|
547
568
|
if (knownCommandsCache) return knownCommandsCache;
|
|
548
569
|
const found = new Set(["help"]);
|
|
549
|
-
for (const match of dispatch.toString().matchAll(/command\s*===\s*["']([^"']+)["']/g)) {
|
|
570
|
+
for (const match of (main.toString() + dispatch.toString()).matchAll(/command\s*===\s*["']([^"']+)["']/g)) {
|
|
550
571
|
found.add(match[1]);
|
|
551
572
|
}
|
|
552
573
|
knownCommandsCache = [...found];
|
|
@@ -593,7 +614,7 @@ function closestCommand(input) {
|
|
|
593
614
|
return bestDistance <= budget ? best : null;
|
|
594
615
|
}
|
|
595
616
|
|
|
596
|
-
export async function main(argv) {
|
|
617
|
+
export async function main(argv, { authentication } = {}) {
|
|
597
618
|
const args = parseArgs(argv);
|
|
598
619
|
// `npx --yes -p <spec> campaigns-os <command>` and `npx --yes <spec>
|
|
599
620
|
// campaigns-os <command>` both hand the bin its own name as the first
|
|
@@ -603,65 +624,88 @@ export async function main(argv) {
|
|
|
603
624
|
if (args._[0] === "campaigns-os") args._.shift();
|
|
604
625
|
const command = args._[0] || "help";
|
|
605
626
|
|
|
606
|
-
//
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
627
|
+
// Everything below — dispatch and the onFinish that reads the verdict — runs
|
|
628
|
+
// inside ONE refusal scope, so a refusal raised by this invocation is visible
|
|
629
|
+
// only to this invocation's persistence step. Two main() calls interleaved
|
|
630
|
+
// in-process (a test, an embedding host) no longer share the verdict.
|
|
631
|
+
return runWithRefusalScope(async () => {
|
|
632
|
+
// Authentication never recovers/remits run sessions or records argv in a
|
|
633
|
+
// lifecycle journal. Credentials belong only in the user credential store.
|
|
634
|
+
if (command === "login" || command === "logout") {
|
|
635
|
+
const { runAuthentication } = await import("./login.mjs");
|
|
636
|
+
return runAuthentication(argv[0] === "campaigns-os" ? argv.slice(1) : argv, authentication);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// An offline sample must not recover sessions or emit lifecycle evidence.
|
|
640
|
+
if (command === "demo") {
|
|
641
|
+
// Validate raw tokens here: parsing loses duplicate flags. The private
|
|
642
|
+
// dispatcher then rechecks the parsed shape and extracts the target.
|
|
643
|
+
demoArguments(args, argv);
|
|
644
|
+
await dispatch(command, args);
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
614
647
|
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
648
|
+
// Diagnostic export is an inspection, including when a run is active or
|
|
649
|
+
// stale. Bypass session sweeping, ambient resolution, and lifecycle capture
|
|
650
|
+
// so no closeout/remit or journal write can occur before the projection.
|
|
651
|
+
if (command === "tooling" && args._[1] === "diagnose") {
|
|
652
|
+
const result = toolingDiagnose(args);
|
|
653
|
+
console.log(args.json ? JSON.stringify(result, null, 2) : diagnosticTextLines(result).join("\n"));
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
623
656
|
|
|
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
|
-
|
|
657
|
+
// Ambient run session (Tier 3): when `run start` is active, every command
|
|
658
|
+
// shares its run_id WITHOUT --run-id. Explicit --run-id still wins. Resolved
|
|
659
|
+
// ONCE here and threaded through dispatch + persistence so the run_id a
|
|
660
|
+
// command is tagged with and the journal it writes to come from a single
|
|
661
|
+
// read (no TOCTOU skew if the session changes mid-run).
|
|
662
|
+
//
|
|
663
|
+
// Before that read, close out any STALE session at the root this command is
|
|
664
|
+
// about to open a new one in. findRunSession ignores stale sessions so a new
|
|
665
|
+
// run never inherits an old run_id — but an ignored session was also an
|
|
666
|
+
// abandoned one: nine of them were found lingering with no Run Record and
|
|
667
|
+
// nothing remitted. Closing out is best-effort and never blocks the command.
|
|
668
|
+
const storageInspection = command === "sdk" && args._[1] === "storage-check";
|
|
669
|
+
// `readback` bypasses session resolution entirely, sweep included. Its
|
|
670
|
+
// `--packet` is a readback OVERRIDE naming the Build Packet to project, not a
|
|
671
|
+
// Build Packet to act on, and ambientRunSession treats that flag as a session
|
|
672
|
+
// locator: it read the named file whole through readJson, so a 40 MB packet
|
|
673
|
+
// was loaded into memory past readback's own 32 MiB bound before readback
|
|
674
|
+
// ever saw it, and a valid override exited 1 whenever some active session was
|
|
675
|
+
// bound to a different packet. Neither belongs to a command declared
|
|
676
|
+
// read-only. The lifecycle wrapper below still runs; the read-only exemption
|
|
677
|
+
// lives in persistLifecycleIfRequested, which writes no entry for readback.
|
|
678
|
+
const readOnlyProjection = command === "readback";
|
|
679
|
+
const sweptStale = storageInspection || readOnlyProjection ? [] : await closeOutStaleRunSessions(command, args);
|
|
680
|
+
const ambient = readOnlyProjection ? null : ambientRunSession(args);
|
|
681
|
+
|
|
682
|
+
// Wrap every command in the lifecycle instrumentation (T6): it captures the
|
|
683
|
+
// command, its argv shape, exit status, and timing. Re-throws unchanged so
|
|
684
|
+
// the CLI exit code is unaffected. Persistence runs via onFinish so it fires
|
|
685
|
+
// on BOTH the success and error paths — a command that THROWS (the most
|
|
686
|
+
// valuable failure telemetry) is recorded too, not just clean exits.
|
|
687
|
+
// Persistence is OPT-IN — an explicit --lifecycle-journal /
|
|
688
|
+
// CAMPAIGNS_OS_LIFECYCLE_LOG, or an active run session. With none, behavior
|
|
689
|
+
// is identical to before.
|
|
690
|
+
//
|
|
691
|
+
// `sessionHolder` is per-invocation, NOT module state: when start/
|
|
692
|
+
// prepare-build auto-open a run session mid-command, they publish it here
|
|
693
|
+
// so onFinish persists this command's own lifecycle entry into the new
|
|
694
|
+
// session — without two interleaved invocations ever sharing a session.
|
|
695
|
+
const sessionHolder = { current: ambient, autoStarted: false, adopted: false, qaResult: null, sweptStale };
|
|
696
|
+
await withCommandLifecycle(
|
|
697
|
+
{
|
|
698
|
+
command,
|
|
699
|
+
argvShape: argvShape(args),
|
|
700
|
+
runId: optionalString(args["run-id"]) || ambient?.session?.run_id || null,
|
|
701
|
+
onFinish: async (lifecycle, thrown) => {
|
|
702
|
+
persistLifecycleIfRequested(args, command, lifecycle, sessionHolder, thrown);
|
|
703
|
+
await autoEndRunSessionAfterTerminalQa(args, command, sessionHolder, thrown);
|
|
704
|
+
},
|
|
661
705
|
},
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
);
|
|
706
|
+
(recorder) => dispatch(command, args, recorder, ambient, sessionHolder),
|
|
707
|
+
);
|
|
708
|
+
});
|
|
665
709
|
}
|
|
666
710
|
|
|
667
711
|
function ambientRunSession(args = {}) {
|
|
@@ -777,14 +821,72 @@ function resolveLifecycleJournal(args, { ambient = null, fallbackDir = null } =
|
|
|
777
821
|
return fallbackDir ? join(resolve(fallbackDir), LIFECYCLE_JOURNAL_REL_PATH) : null;
|
|
778
822
|
}
|
|
779
823
|
|
|
824
|
+
// The commands and subcommands that actually IMPLEMENT `--dry-run`. The flag
|
|
825
|
+
// reaches every handler through a permissive parseArgs, so it is silently
|
|
826
|
+
// accepted everywhere — and the lifecycle exemption below, scoped to the flag
|
|
827
|
+
// alone, therefore fired on commands that ignore it: `qa run --dry-run` placed
|
|
828
|
+
// orders while writing no journal entry, and (see runSessionEndArgs) carried
|
|
829
|
+
// the flag into its own auto-end, which assembled no Run Record and left the
|
|
830
|
+
// session open. Keyed by `command`, or `command <args._[1]>` where the flag
|
|
831
|
+
// belongs to one subcommand. A command outside this set given `--dry-run`
|
|
832
|
+
// behaves exactly as it did before: it journals if it otherwise would, and it
|
|
833
|
+
// is not refused — refusing unknown flags is separate work.
|
|
834
|
+
const DRY_RUN_COMMANDS = new Set([
|
|
835
|
+
"page-kit sync",
|
|
836
|
+
"spec derive",
|
|
837
|
+
"install-skills",
|
|
838
|
+
"install-agent-context",
|
|
839
|
+
"run-record",
|
|
840
|
+
"run end",
|
|
841
|
+
"qa publish",
|
|
842
|
+
"checkpoint waive",
|
|
843
|
+
"theme waive",
|
|
844
|
+
]);
|
|
845
|
+
|
|
846
|
+
function commandImplementsDryRun(command, args = {}) {
|
|
847
|
+
return DRY_RUN_COMMANDS.has(command) || DRY_RUN_COMMANDS.has(`${command} ${args._?.[1]}`);
|
|
848
|
+
}
|
|
849
|
+
|
|
780
850
|
// Append the command's lifecycle entry only when capture is active: an explicit
|
|
781
851
|
// flag/env, or an ambient run session. Never throws — a lifecycle write must
|
|
782
852
|
// not break a command (telemetry never blocks a build). `help` is a no-op
|
|
783
853
|
// command and is not worth recording.
|
|
784
|
-
function persistLifecycleIfRequested(args, command, lifecycle, sessionHolder) {
|
|
854
|
+
function persistLifecycleIfRequested(args, command, lifecycle, sessionHolder, thrown) {
|
|
785
855
|
if (command === "help" || (command === "sdk" && args._[1] === "storage-check")) return;
|
|
856
|
+
// Three rules about what NEVER reaches the journal, whichever way the journal
|
|
857
|
+
// was selected (--lifecycle-journal, CAMPAIGNS_OS_LIFECYCLE_LOG, or an
|
|
858
|
+
// ambient run session). The in-process lifecycle object is still built; only
|
|
859
|
+
// the persistence below — the journal append and the deviation entry that
|
|
860
|
+
// follows it — is skipped, so a suppressed command still exits as before.
|
|
861
|
+
// 1. --no-write writes nothing, the journal included (issue #459: `run
|
|
862
|
+
// status --no-write` under an ambient session still created
|
|
863
|
+
// .campaign-runtime/command-lifecycle.jsonl).
|
|
864
|
+
// 2. A refused INVOCATION records nothing — an unknown top-level command,
|
|
865
|
+
// an unknown subcommand (`tooling statuss`), or a flag the command
|
|
866
|
+
// refuses up front (`standardize --dryrun`). None of them reached a
|
|
867
|
+
// handler, so a typo must not materialize a journal under the target.
|
|
868
|
+
// The tag the refusal carries IS the mechanism, read two ways: on the
|
|
869
|
+
// thrown error, or via refusalSeen() when the refusal was caught and
|
|
870
|
+
// rendered instead of thrown. There is deliberately no command-list
|
|
871
|
+
// backstop here — knownCommands() is regex-harvested and documented as
|
|
872
|
+
// fragile, so a second reading of it would be a second command list that
|
|
873
|
+
// could disagree with dispatch.
|
|
874
|
+
// 3. `run status` is read-only: it never sweeps and never journals.
|
|
875
|
+
if (args["no-write"] === true) return;
|
|
876
|
+
if (refusalSeen() || thrown?.code === REFUSED_INVOCATION) return;
|
|
877
|
+
if (command === "run" && args._[1] === "status") return;
|
|
786
878
|
// An inspection must not append to a delivered campaign's active run either.
|
|
787
|
-
|
|
879
|
+
// (--no-write is handled above, so only the read-only `doctor` form is left.)
|
|
880
|
+
if (command === "doctor" && args.packet && args.write !== true) return;
|
|
881
|
+
// The same rule, per-flag, for every command that takes `--dry-run`: the
|
|
882
|
+
// flag's whole promise is that the invocation writes nothing under the
|
|
883
|
+
// target, and the journal lives under the target. Only for the commands that
|
|
884
|
+
// make that promise, though — DRY_RUN_COMMANDS above.
|
|
885
|
+
if (args["dry-run"] === true && commandImplementsDryRun(command, args)) return;
|
|
886
|
+
// `readback` is declared read-only for the whole command, not per-flag: it
|
|
887
|
+
// writes nothing under the target, so a journal entry would be the one write
|
|
888
|
+
// its own contract forbids. Skipped the way doctor inspection is skipped.
|
|
889
|
+
if (command === "readback") return;
|
|
788
890
|
const ambient = sessionHolder?.current || null;
|
|
789
891
|
// A session auto-started DURING this command (start/prepare-build) is
|
|
790
892
|
// published into sessionHolder by autoStartRunSession; this command's own
|
|
@@ -1009,9 +1111,18 @@ async function autoEndRunSessionAfterTerminalQa(args, command, sessionHolder, th
|
|
|
1009
1111
|
return;
|
|
1010
1112
|
}
|
|
1011
1113
|
|
|
1114
|
+
// `dry-run` is inheritable because `run end --dry-run` hands it to
|
|
1115
|
+
// run-record on purpose. The invoking command here is `qa run`, which does
|
|
1116
|
+
// not implement the flag, so inheriting it would turn its own auto-end into
|
|
1117
|
+
// a dry run: no Run Record written, and the session left open after a
|
|
1118
|
+
// terminal QA. Every other inheritable flag `qa run` may carry is one
|
|
1119
|
+
// run-record reads the same way whoever passed it.
|
|
1120
|
+
const extraArgs = { ...args, "qa-verdict": result.local_path };
|
|
1121
|
+
if (!commandImplementsDryRun(command, args)) delete extraArgs["dry-run"];
|
|
1122
|
+
|
|
1012
1123
|
const summary = await closeRunSession(updatedFound, {
|
|
1013
1124
|
packet,
|
|
1014
|
-
extraArgs
|
|
1125
|
+
extraArgs,
|
|
1015
1126
|
silent: true,
|
|
1016
1127
|
promptForConsent: false,
|
|
1017
1128
|
onError: (error) => process.stderr.write(`[campaigns-os] run session auto-end skipped after QA: ${error.message}\n`),
|
|
@@ -1083,7 +1194,7 @@ async function dispatch(command, args, recorder = NOOP_RECORDER, ambient = null,
|
|
|
1083
1194
|
|
|
1084
1195
|
if (command === "bundle") {
|
|
1085
1196
|
const subcommand = args._[1] || "check";
|
|
1086
|
-
if (subcommand !== "check") throw
|
|
1197
|
+
if (subcommand !== "check") throw refused('Unknown bundle subcommand. Use: campaigns-os bundle check --packet <campaign-runtime.build.json> [--require-qa] [--json].');
|
|
1087
1198
|
const { inspectSidecarBundle, sidecarBundleReadinessLine } = await import("./sidecar-bundle.mjs");
|
|
1088
1199
|
const result = inspectSidecarBundle({
|
|
1089
1200
|
packetPath: requireArg(args, "packet"),
|
|
@@ -1097,10 +1208,10 @@ async function dispatch(command, args, recorder = NOOP_RECORDER, ambient = null,
|
|
|
1097
1208
|
}
|
|
1098
1209
|
|
|
1099
1210
|
if (command === "sdk") {
|
|
1100
|
-
if (args._[1] !== "storage-check" || args._.length !== 2) throw
|
|
1211
|
+
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
1212
|
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
|
|
1213
|
+
if (args.json !== undefined && args.json !== true) throw refused("--json is a boolean flag and takes no value.");
|
|
1214
|
+
for (const key of Object.keys(args)) if (!known.has(key)) throw refused(`Unknown SDK storage-check flag: --${key}`);
|
|
1104
1215
|
const { scanSdkStorageCompatibility, formatStorageCompatibilityReport } = await import("./sdk-storage-compatibility.mjs");
|
|
1105
1216
|
const result = scanSdkStorageCompatibility({
|
|
1106
1217
|
cwd: requireArg(args, "target"),
|
|
@@ -1145,6 +1256,21 @@ async function dispatch(command, args, recorder = NOOP_RECORDER, ambient = null,
|
|
|
1145
1256
|
return;
|
|
1146
1257
|
}
|
|
1147
1258
|
|
|
1259
|
+
if (command === "readback") {
|
|
1260
|
+
// Read-only projection over a target's already-emitted artifacts. It owns
|
|
1261
|
+
// its own exit codes (0 for any projection it could form, 2 for a request
|
|
1262
|
+
// that cannot form one) rather than throwing, so a usage error reads as a
|
|
1263
|
+
// one-line refusal instead of a stack-shaped CLI error.
|
|
1264
|
+
const { runReadbackCommand } = await import("./readback.mjs");
|
|
1265
|
+
const { exitCode, text } = runReadbackCommand(args);
|
|
1266
|
+
if (exitCode === 0) process.stdout.write(text);
|
|
1267
|
+
else {
|
|
1268
|
+
process.stderr.write(text);
|
|
1269
|
+
process.exitCode = 2;
|
|
1270
|
+
}
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1148
1274
|
if (command === "validate-assembly-report") {
|
|
1149
1275
|
const reportPath = requireArg(args, "report");
|
|
1150
1276
|
const result = validateAssemblyReport(readJson(resolve(reportPath)));
|
|
@@ -1160,8 +1286,8 @@ async function dispatch(command, args, recorder = NOOP_RECORDER, ambient = null,
|
|
|
1160
1286
|
}
|
|
1161
1287
|
|
|
1162
1288
|
if (command === "install-skills") {
|
|
1163
|
-
if (args.target === true) throw
|
|
1164
|
-
if (args.platform === true) throw
|
|
1289
|
+
if (args.target === true) throw refused("Missing value for --target");
|
|
1290
|
+
if (args.platform === true) throw refused("Missing value for --platform");
|
|
1165
1291
|
const result = installSkills(args.target, Boolean(args["dry-run"]), args.platform);
|
|
1166
1292
|
writeResult(result, args, 0);
|
|
1167
1293
|
return;
|
|
@@ -1172,7 +1298,7 @@ async function dispatch(command, args, recorder = NOOP_RECORDER, ambient = null,
|
|
|
1172
1298
|
// Spelled as inequalities: knownCommands() harvests the top-level
|
|
1173
1299
|
// command literals from this function by an equality pattern that a
|
|
1174
1300
|
// subcommand equality would also match.
|
|
1175
|
-
if (subcommand !== "sync" && subcommand !== "parity") throw
|
|
1301
|
+
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
1302
|
const parity = subcommand !== "sync";
|
|
1177
1303
|
const result = parity ? pageKitParityCommand(args) : pageKitSyncCommand(args);
|
|
1178
1304
|
if (args.json) console.log(JSON.stringify(result, null, 2));
|
|
@@ -1185,7 +1311,7 @@ async function dispatch(command, args, recorder = NOOP_RECORDER, ambient = null,
|
|
|
1185
1311
|
const subcommand = args._[1] || null;
|
|
1186
1312
|
// Inequality on purpose: knownCommands() harvests top-level command
|
|
1187
1313
|
// literals by an equality pattern a subcommand equality would also match.
|
|
1188
|
-
if (subcommand !== "derive") throw
|
|
1314
|
+
if (subcommand !== "derive") throw refused("Unknown spec subcommand. Use: campaigns-os spec derive --packet <campaign-runtime.build.json> [--dry-run] [--json].");
|
|
1189
1315
|
const result = await specDeriveWithMapWriteback(args);
|
|
1190
1316
|
if (args.json) console.log(JSON.stringify(result, null, 2));
|
|
1191
1317
|
else for (const line of specDeriveWriteMapTextLines(result)) console.log(line);
|
|
@@ -1194,8 +1320,11 @@ async function dispatch(command, args, recorder = NOOP_RECORDER, ambient = null,
|
|
|
1194
1320
|
}
|
|
1195
1321
|
|
|
1196
1322
|
if (command === "tooling") {
|
|
1197
|
-
const result =
|
|
1198
|
-
|
|
1323
|
+
const result = await toolingStatusCommand(args);
|
|
1324
|
+
// The revision line is a header, so a mismatch is stated before the status
|
|
1325
|
+
// an operator would otherwise read as fine — and the full status still
|
|
1326
|
+
// prints, because the exit code is set after the render, not instead of it.
|
|
1327
|
+
writeResult(result, args, result.ok ? 0 : 2, { headerLines: [...skillsRevisionTextLines(result), ...pinTextLines(result)] });
|
|
1199
1328
|
return;
|
|
1200
1329
|
}
|
|
1201
1330
|
|
|
@@ -1252,7 +1381,7 @@ async function dispatch(command, args, recorder = NOOP_RECORDER, ambient = null,
|
|
|
1252
1381
|
|
|
1253
1382
|
const suggestion = closestCommand(command);
|
|
1254
1383
|
const didYouMean = suggestion ? ` Did you mean "${suggestion}"?` : "";
|
|
1255
|
-
throw
|
|
1384
|
+
throw refused(
|
|
1256
1385
|
`Unknown command: ${command}.${didYouMean} Run \`campaigns-os --help\` to see available commands.`,
|
|
1257
1386
|
);
|
|
1258
1387
|
}
|
|
@@ -1277,12 +1406,25 @@ function parseArgs(argv) {
|
|
|
1277
1406
|
return args;
|
|
1278
1407
|
}
|
|
1279
1408
|
|
|
1409
|
+
// The shared "missing required flag" refusal. Every call site resolves flags
|
|
1410
|
+
// at the top of its handler, before the command reads or writes anything, so
|
|
1411
|
+
// this is always an up-front refusal and carries the tag.
|
|
1280
1412
|
function requireArg(args, key) {
|
|
1281
1413
|
const value = args[key];
|
|
1282
|
-
if (!isNonEmptyString(value)) throw
|
|
1414
|
+
if (!isNonEmptyString(value)) throw refused(`Missing required --${key}`);
|
|
1283
1415
|
return value;
|
|
1284
1416
|
}
|
|
1285
1417
|
|
|
1418
|
+
// `--dry-run` is a bare flag on every command that takes one. The shared
|
|
1419
|
+
// parser would read a following token as its value, so `--dry-run true` must
|
|
1420
|
+
// fail rather than quietly become a real write or a real send.
|
|
1421
|
+
function isDryRun(args) {
|
|
1422
|
+
if (Object.hasOwn(args, "dry-run") && args["dry-run"] !== true) {
|
|
1423
|
+
throw refused(`--dry-run takes no value (got ${JSON.stringify(args["dry-run"])}); write \`--dry-run\` on its own, after the other flags.`);
|
|
1424
|
+
}
|
|
1425
|
+
return args["dry-run"] === true;
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1286
1428
|
function isObject(value) {
|
|
1287
1429
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
1288
1430
|
}
|
|
@@ -1355,7 +1497,7 @@ async function resolveSpecPath(args, opts = {}) {
|
|
|
1355
1497
|
const mapId = String(args["map-id"]).trim();
|
|
1356
1498
|
const targetRepo = opts.targetRepo || (args.target ? resolve(args.target) : null);
|
|
1357
1499
|
if (!targetRepo) {
|
|
1358
|
-
throw
|
|
1500
|
+
throw refused("--map-id requires --target (so the fetched spec can be cached under <target>/.campaign-runtime/).");
|
|
1359
1501
|
}
|
|
1360
1502
|
const proxyBase = optionalString(args["proxy-base"], DEFAULT_PROXY_BASE);
|
|
1361
1503
|
const cacheDir = join(targetRepo, ".campaign-runtime", "fetched-specs");
|
|
@@ -1374,7 +1516,7 @@ async function resolveSpecPath(args, opts = {}) {
|
|
|
1374
1516
|
algorithm: "map-store-v1", local_spec_material_hash: specMaterialHash(spec) },
|
|
1375
1517
|
};
|
|
1376
1518
|
}
|
|
1377
|
-
throw
|
|
1519
|
+
throw refused(
|
|
1378
1520
|
"Either --spec <path> or --map-id <id> is required. " +
|
|
1379
1521
|
"Pass a local CampaignSpec (--spec <path-to-campaignspec.json>) " +
|
|
1380
1522
|
"or fetch one from Map Builder (--map-id <id> --target <page-kit-dir>).",
|
|
@@ -2444,6 +2586,9 @@ function prepareBuild(args, options = {}) {
|
|
|
2444
2586
|
const packet = {
|
|
2445
2587
|
schema_version: PACKET_SCHEMA,
|
|
2446
2588
|
generated_at: new Date().toISOString(),
|
|
2589
|
+
// The kernel version that prepared this packet: the second pin source
|
|
2590
|
+
// `tooling status` reads when the project declares no exact devDependency.
|
|
2591
|
+
campaigns_os_version: packageVersion(),
|
|
2447
2592
|
campaign: {
|
|
2448
2593
|
public_route_slug: publicRouteSlug,
|
|
2449
2594
|
...(specRouteRoot ? { route_root: specRouteRoot } : {}),
|
|
@@ -3218,7 +3363,7 @@ function rejectUnknownStandardizeFlags(args) {
|
|
|
3218
3363
|
const valueHint = unknown.some((key) => key.includes("="))
|
|
3219
3364
|
? " A flag takes its value as the next argument (--flag value), not --flag=value."
|
|
3220
3365
|
: "";
|
|
3221
|
-
throw
|
|
3366
|
+
throw refused(
|
|
3222
3367
|
`Unknown flag${unknown.length > 1 ? "s" : ""} for standardize: ${unknown.map((key) => `--${key}`).join(", ")}.${valueHint} Known flags: ${STANDARDIZE_FLAGS.map((key) => `--${key}`).join(", ")}.`,
|
|
3223
3368
|
);
|
|
3224
3369
|
}
|
|
@@ -3227,7 +3372,7 @@ function standardizationReportCommand(args) {
|
|
|
3227
3372
|
rejectUnknownStandardizeFlags(args);
|
|
3228
3373
|
const target = optionalString(args.target);
|
|
3229
3374
|
if (!target) {
|
|
3230
|
-
throw
|
|
3375
|
+
throw refused("standardize requires --target <campaign-repo> (a Page Kit root, a parent repo, or a Campaign Cart application checkout).");
|
|
3231
3376
|
}
|
|
3232
3377
|
const family = optionalString(args.family) || optionalString(args["template-family"]);
|
|
3233
3378
|
const slug = optionalString(args.slug);
|
|
@@ -3283,7 +3428,7 @@ function standardizationReportCommand(args) {
|
|
|
3283
3428
|
function themeCommand(args) {
|
|
3284
3429
|
const subcommand = args._[1] || "inspect";
|
|
3285
3430
|
if (!["inspect", "generate", "waive"].includes(subcommand)) {
|
|
3286
|
-
throw
|
|
3431
|
+
throw refused(`Unknown theme subcommand "${subcommand}". Use: inspect | generate | waive.`);
|
|
3287
3432
|
}
|
|
3288
3433
|
if (subcommand === "waive") return themeWaive(args);
|
|
3289
3434
|
const packetPath = resolve(requireArg(args, "packet"));
|
|
@@ -3325,20 +3470,25 @@ function themeCommand(args) {
|
|
|
3325
3470
|
// improvising past advisory prose.
|
|
3326
3471
|
export function themeWaive(args) {
|
|
3327
3472
|
const packetPath = resolve(requireArg(args, "packet"));
|
|
3473
|
+
const dryRun = isDryRun(args);
|
|
3328
3474
|
const packet = readJson(packetPath);
|
|
3329
3475
|
const reason = optionalString(args.reason);
|
|
3330
|
-
|
|
3476
|
+
// A missing flag, raised after reading nothing but argv and the packet: a
|
|
3477
|
+
// refusal, so the journal records nothing for it (docs/effects.md `*refused*`).
|
|
3478
|
+
if (!reason) throw refused("theme waive requires --reason \"<why the starter palette is acceptable for this campaign>\".");
|
|
3331
3479
|
// The same attribution rule as `checkpoint waive`: a named human, no
|
|
3332
3480
|
// placeholder, an expiry (when given) that lies in the future and is
|
|
3333
3481
|
// recorded. A bound is not demanded here: the theme gate's waiver has always
|
|
3334
3482
|
// been open-ended, and QA re-surfaces the starter palette on every run.
|
|
3335
|
-
|
|
3483
|
+
// A shared validator of argv alone, still ahead of the report read: its
|
|
3484
|
+
// throws are refusals at this call site (the position decides, not the file).
|
|
3485
|
+
const waiver = refusing(() => validateWaiverAttribution({
|
|
3336
3486
|
reason,
|
|
3337
3487
|
waivedBy: args["waived-by"] == null ? null : String(args["waived-by"]),
|
|
3338
3488
|
expiresAt: args["expires-at"] == null ? null : String(args["expires-at"]),
|
|
3339
3489
|
requireBound: false,
|
|
3340
3490
|
label: "theme waive",
|
|
3341
|
-
});
|
|
3491
|
+
}));
|
|
3342
3492
|
const workspace = resolveCampaignWorkspace(packetPath, {
|
|
3343
3493
|
packet,
|
|
3344
3494
|
reportPath: args.report ? resolve(args.report) : undefined,
|
|
@@ -3346,7 +3496,7 @@ export function themeWaive(args) {
|
|
|
3346
3496
|
});
|
|
3347
3497
|
const { reportPath } = workspace;
|
|
3348
3498
|
if (!existsSync(reportPath)) throw new Error(`theme waive needs an assembly report at ${reportPath}; run prepare-build/start first.`);
|
|
3349
|
-
|
|
3499
|
+
const recordWaiver = (report) => {
|
|
3350
3500
|
report.theme = report.theme && isObject(report.theme)
|
|
3351
3501
|
? { ...report.theme, waiver }
|
|
3352
3502
|
: { status: "skipped", css_path: null, load_order: "not-applied", commerce_pages: [], evidence: [], warnings: [], repair_loop_defect: null, waiver };
|
|
@@ -3355,12 +3505,37 @@ export function themeWaive(args) {
|
|
|
3355
3505
|
`Theme gate waived by ${waiver.waived_by} at ${waiver.waived_at}: ${reason}`,
|
|
3356
3506
|
];
|
|
3357
3507
|
return report;
|
|
3358
|
-
}
|
|
3508
|
+
};
|
|
3509
|
+
// Every check above is the real command's; only the commit is skipped. The
|
|
3510
|
+
// readiness block is not reported for a dry run because doctor reads it off
|
|
3511
|
+
// the report on disk, which by construction still has no waiver on it.
|
|
3512
|
+
//
|
|
3513
|
+
// Both modes go through the committing path itself (see
|
|
3514
|
+
// commitWaiverToAssemblyReport): the dry run used to return on the existsSync
|
|
3515
|
+
// check alone, so a torn report was reported as a successful `would_write`
|
|
3516
|
+
// with exit 0 while the real invocation refused with "Assembly Report ... is
|
|
3517
|
+
// not valid JSON" and exit 1, and a report that is not an object was previewed
|
|
3518
|
+
// as writable while the commit refused it. Validation must never be weaker
|
|
3519
|
+
// under --dry-run than without it.
|
|
3520
|
+
commitWaiverToAssemblyReport(workspace, recordWaiver, {
|
|
3359
3521
|
// #171: the waiver changes what doctor would conclude; the retained doctor
|
|
3360
3522
|
// sidecar (if any) now predates it.
|
|
3361
3523
|
command: "theme waive",
|
|
3362
3524
|
staleReason: `A theme-gate waiver was recorded after this doctor snapshot. Re-run ${cmd("doctor")} (or next) for current state.`,
|
|
3363
|
-
});
|
|
3525
|
+
}, { dryRun });
|
|
3526
|
+
if (dryRun) {
|
|
3527
|
+
return {
|
|
3528
|
+
ok: true,
|
|
3529
|
+
status: "dry_run",
|
|
3530
|
+
dry_run: true,
|
|
3531
|
+
action: "theme-waive",
|
|
3532
|
+
gate: "theme_gate",
|
|
3533
|
+
waiver,
|
|
3534
|
+
report_path: reportPath,
|
|
3535
|
+
would_write: reportPath,
|
|
3536
|
+
note: "Dry run: nothing was written and the doctor sidecar was not marked stale. Re-run without --dry-run to record this waiver.",
|
|
3537
|
+
};
|
|
3538
|
+
}
|
|
3364
3539
|
return {
|
|
3365
3540
|
ok: true,
|
|
3366
3541
|
...waiveReadiness(packetPath, reportPath),
|
|
@@ -3378,6 +3553,44 @@ export function themeWaive(args) {
|
|
|
3378
3553
|
// "unknown" fallback. Doctor is re-run rather than patched from the pre-waive
|
|
3379
3554
|
// result because a waiver changes what every other gate concludes about the
|
|
3380
3555
|
// stage. Nothing is persisted here; the sidecar was already marked stale.
|
|
3556
|
+
/**
|
|
3557
|
+
* The one route both waive commands take to the Assembly Report, real or
|
|
3558
|
+
* previewed. `commitAssemblyReport` is called identically in both modes — same
|
|
3559
|
+
* workspace, same mutator, same options — so every check the committing path
|
|
3560
|
+
* makes runs on both: the report must exist, it must parse (a torn one fails by
|
|
3561
|
+
* name), the mutator's own refusals fire, its result must be an Assembly Report
|
|
3562
|
+
* object, and the derived summary is restated over that result. A dry run
|
|
3563
|
+
* differs in one statement: this wrapper — not the mutator — returns `null` to
|
|
3564
|
+
* commitAssemblyReport, which is its "nothing to write" answer, so the report
|
|
3565
|
+
* is not rewritten and the doctor sidecar is not stamped stale. Nothing is
|
|
3566
|
+
* re-implemented and nothing is skipped but the write itself. A mutator that
|
|
3567
|
+
* returns nothing does not get to borrow that sentinel: it is a bug, and it
|
|
3568
|
+
* throws here on both paths.
|
|
3569
|
+
*
|
|
3570
|
+
* The result-shape check and the summary restatement sit here rather than being
|
|
3571
|
+
* left to commitAssemblyReport alone because they must run in BOTH modes and
|
|
3572
|
+
* commitAssemblyReport reaches its own copies only on the way to the write.
|
|
3573
|
+
* Running them here means one message and one order, not two: the real path
|
|
3574
|
+
* now fails on this line and never on the copy in stage-ledger.mjs, so the two
|
|
3575
|
+
* cannot drift into different text.
|
|
3576
|
+
*/
|
|
3577
|
+
export function commitWaiverToAssemblyReport(workspace, mutate, options, { dryRun = false } = {}) {
|
|
3578
|
+
const previewOrCommit = (report) => {
|
|
3579
|
+
const mutated = mutate(report);
|
|
3580
|
+
// null and undefined are refused here rather than forwarded. Both waive
|
|
3581
|
+
// mutators always return the report they built, and commitAssemblyReport
|
|
3582
|
+
// reads a null result as "nothing to write" — so a mutator that forgot to
|
|
3583
|
+
// return would skip the write silently while the command still reported the
|
|
3584
|
+
// waiver recorded. That is a bug in the mutator and fails loudly. The dry
|
|
3585
|
+
// run's own "do not write" is this wrapper's null below, not the mutator's.
|
|
3586
|
+
if (!isPlainObject(mutated)) throw new TypeError("commitAssemblyReport mutate(report) must return an Assembly Report object.");
|
|
3587
|
+
if (!dryRun) return mutated;
|
|
3588
|
+
applyDerivedAssemblyReportSummary(mutated);
|
|
3589
|
+
return null;
|
|
3590
|
+
};
|
|
3591
|
+
return commitAssemblyReport(workspace, previewOrCommit, options);
|
|
3592
|
+
}
|
|
3593
|
+
|
|
3381
3594
|
function waiveReadiness(packetPath, reportPath) {
|
|
3382
3595
|
const doctor = doctorPacket(packetPath, { reportPath });
|
|
3383
3596
|
return { status: doctor.status, next_stage: doctor.next?.stage || null, next_stage_reason: doctor.next?.reason || null };
|
|
@@ -3400,14 +3613,14 @@ function requireValidPolishCaptureReport(report, reportPath) {
|
|
|
3400
3613
|
export async function polishCaptureCommand(args, options = {}) {
|
|
3401
3614
|
const subcommand = args?._?.[1] || "help";
|
|
3402
3615
|
if (subcommand !== "capture") {
|
|
3403
|
-
throw
|
|
3616
|
+
throw refused(
|
|
3404
3617
|
`Unknown polish subcommand. Use: ${cmd("polish")} capture --packet <campaign-runtime.build.json> --base-url <url> [--report <json>] [--headed] [--auth-cookie <cookie>] [--json].`,
|
|
3405
3618
|
);
|
|
3406
3619
|
}
|
|
3407
3620
|
const packetPath = resolve(requireArg(args, "packet"));
|
|
3408
3621
|
const baseUrl = requireArg(args, "base-url");
|
|
3409
|
-
if (args.report === true) throw
|
|
3410
|
-
if (args["auth-cookie"] === true) throw
|
|
3622
|
+
if (args.report === true) throw refused("Missing value for --report");
|
|
3623
|
+
if (args["auth-cookie"] === true) throw refused("Missing value for --auth-cookie");
|
|
3411
3624
|
|
|
3412
3625
|
const packet = readJson(packetPath);
|
|
3413
3626
|
const workspace = resolveCampaignWorkspace(packetPath, {
|
|
@@ -3547,13 +3760,14 @@ function waiveOrRefuse(args, run, { gate = null, registeredGates = [] } = {}) {
|
|
|
3547
3760
|
function checkpointCommand(args) {
|
|
3548
3761
|
const subcommand = args._[1] || "help";
|
|
3549
3762
|
if (subcommand !== "waive") {
|
|
3550
|
-
throw
|
|
3763
|
+
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
3764
|
}
|
|
3552
3765
|
return checkpointWaive(args);
|
|
3553
3766
|
}
|
|
3554
3767
|
|
|
3555
3768
|
export function checkpointWaive(args) {
|
|
3556
3769
|
const packetPath = resolve(requireArg(args, "packet"));
|
|
3770
|
+
const dryRun = isDryRun(args);
|
|
3557
3771
|
const gateId = requireArg(args, "gate").trim();
|
|
3558
3772
|
const reason = requireArg(args, "reason");
|
|
3559
3773
|
const waivedBy = requireArg(args, "waived-by");
|
|
@@ -3570,7 +3784,7 @@ export function checkpointWaive(args) {
|
|
|
3570
3784
|
|
|
3571
3785
|
const doctor = doctorPacket(packetPath, { reportPath });
|
|
3572
3786
|
let waiver = null;
|
|
3573
|
-
|
|
3787
|
+
const recordWaiver = (report) => {
|
|
3574
3788
|
const gate = evaluateCheckpointRegistry(CHECKPOINT_EVALUATORS, gateId, { doctor, packet, report });
|
|
3575
3789
|
if (!gate) throw new Error(`Checkpoint gate "${gateId}" has no current evidence; repair the packet/spec/target and re-run doctor.`);
|
|
3576
3790
|
if (gate.status !== "blocked") {
|
|
@@ -3591,10 +3805,30 @@ export function checkpointWaive(args) {
|
|
|
3591
3805
|
`Checkpoint waiver: ${gateId} waived by ${waiver.waived_by} at ${waiver.waived_at}: ${waiver.reason}`,
|
|
3592
3806
|
];
|
|
3593
3807
|
return updated;
|
|
3594
|
-
}
|
|
3808
|
+
};
|
|
3809
|
+
// The gate registry decides waivability from the report, so both modes go
|
|
3810
|
+
// through the committing path itself (see commitWaiverToAssemblyReport): the
|
|
3811
|
+
// report is read and parsed the same way and the very same mutator runs over
|
|
3812
|
+
// it, so every refusal above fires exactly as it would for real. Only the
|
|
3813
|
+
// write and the doctor-sidecar stale stamp are skipped, and with them the
|
|
3814
|
+
// readiness block, which doctor can only read off a report on disk.
|
|
3815
|
+
commitWaiverToAssemblyReport(workspace, recordWaiver, {
|
|
3595
3816
|
command: "checkpoint waive",
|
|
3596
3817
|
staleReason: `A checkpoint waiver was recorded after this doctor snapshot. Re-run ${cmd("doctor")} (or next) for current state.`,
|
|
3597
|
-
});
|
|
3818
|
+
}, { dryRun });
|
|
3819
|
+
if (dryRun) {
|
|
3820
|
+
return {
|
|
3821
|
+
ok: true,
|
|
3822
|
+
status: "dry_run",
|
|
3823
|
+
dry_run: true,
|
|
3824
|
+
action: "checkpoint-waive",
|
|
3825
|
+
gate: gateId,
|
|
3826
|
+
waiver,
|
|
3827
|
+
report_path: reportPath,
|
|
3828
|
+
would_write: reportPath,
|
|
3829
|
+
note: "Dry run: nothing was written and the doctor sidecar was not marked stale. Re-run without --dry-run to record this waiver.",
|
|
3830
|
+
};
|
|
3831
|
+
}
|
|
3598
3832
|
return {
|
|
3599
3833
|
ok: true,
|
|
3600
3834
|
...waiveReadiness(packetPath, reportPath),
|
|
@@ -4685,16 +4919,10 @@ export function pageKitSyncCommand(args) {
|
|
|
4685
4919
|
const unknown = Object.keys(args).filter((key) => key !== "_" && !PAGE_KIT_SYNC_FLAGS.includes(key));
|
|
4686
4920
|
if (unknown.length) {
|
|
4687
4921
|
const valueHint = unknown.some((key) => key.includes("=")) ? " A flag takes its value as the next argument (--flag value), not --flag=value." : "";
|
|
4688
|
-
throw
|
|
4922
|
+
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
4923
|
}
|
|
4690
4924
|
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;
|
|
4925
|
+
const dryRun = isDryRun(args);
|
|
4698
4926
|
const result = {
|
|
4699
4927
|
ok: false,
|
|
4700
4928
|
action: "page-kit sync",
|
|
@@ -4918,8 +5146,8 @@ export function pageKitSyncCommand(args) {
|
|
|
4918
5146
|
// refereed by doctor. Exactly the derived fields are written; the rest of the
|
|
4919
5147
|
// spec and every other file are untouched. The store-derived fields (the nine
|
|
4920
5148
|
// campaign.store_* Store Profile fields, slice 2) join the write only behind
|
|
4921
|
-
// --from-store <subdomain>, which reads the store's Admin API
|
|
4922
|
-
//
|
|
5149
|
+
// --from-store <subdomain>, which reads the store's Admin API
|
|
5150
|
+
// through gateway login, or the explicit --store-token-source env:<VAR> break-glass path;
|
|
4923
5151
|
// the default run stays offline. --dry-run prints the same diff and writes
|
|
4924
5152
|
// nothing. Exit 2 when the packet, the spec or the target entry is missing,
|
|
4925
5153
|
// or the store cannot be read.
|
|
@@ -4931,17 +5159,17 @@ const SPEC_DERIVE_FLAGS = Object.freeze(["packet", "dry-run", "json", "report",
|
|
|
4931
5159
|
// mistake as an unknown flag: refused before anything is read).
|
|
4932
5160
|
function parseSpecDeriveStoreFlags(args) {
|
|
4933
5161
|
if (!Object.hasOwn(args, "from-store")) {
|
|
4934
|
-
if (Object.hasOwn(args, "store-token-source")) throw
|
|
5162
|
+
if (Object.hasOwn(args, "store-token-source")) throw refused("--store-token-source only applies with --from-store <subdomain>.");
|
|
4935
5163
|
return null;
|
|
4936
5164
|
}
|
|
4937
5165
|
const subdomain = normalizeStoreSubdomain(args["from-store"] === true ? "" : String(args["from-store"] ?? ""));
|
|
4938
5166
|
if (!subdomain) {
|
|
4939
|
-
throw
|
|
5167
|
+
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
5168
|
}
|
|
4941
|
-
let tokenEnv =
|
|
5169
|
+
let tokenEnv = null;
|
|
4942
5170
|
if (Object.hasOwn(args, "store-token-source")) {
|
|
4943
5171
|
const parsed = parseStoreTokenSource(args["store-token-source"] === true ? "" : String(args["store-token-source"] ?? ""));
|
|
4944
|
-
if (parsed.problem) throw
|
|
5172
|
+
if (parsed.problem) throw refused(`--store-token-source ${parsed.problem}`);
|
|
4945
5173
|
tokenEnv = parsed.env;
|
|
4946
5174
|
}
|
|
4947
5175
|
return { subdomain, token_env: tokenEnv };
|
|
@@ -4950,23 +5178,22 @@ function parseSpecDeriveStoreFlags(args) {
|
|
|
4950
5178
|
// `spec derive --from-store`: resolve the credential, read the store, then
|
|
4951
5179
|
// run the same command with the store read in hand. The only network the
|
|
4952
5180
|
// 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 } = {}) {
|
|
5181
|
+
// the result names the credential source, never its value.
|
|
5182
|
+
export async function specDeriveFromStoreCommand(args, { fetchImpl = globalThis.fetch, env = process.env, credentials, warn = console.warn } = {}) {
|
|
4955
5183
|
const store = parseSpecDeriveStoreFlags(args);
|
|
4956
5184
|
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
5185
|
const preflight = specDeriveCommand(args, { store: { ...store, status: "preflight" } });
|
|
4965
5186
|
if (preflight.errors.length) return preflight;
|
|
4966
|
-
|
|
4967
|
-
|
|
4968
|
-
|
|
4969
|
-
|
|
5187
|
+
let read;
|
|
5188
|
+
if (store.token_env) {
|
|
5189
|
+
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.");
|
|
5190
|
+
const token = typeof env[store.token_env] === "string" ? env[store.token_env].trim() : "";
|
|
5191
|
+
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.` };
|
|
5192
|
+
} else {
|
|
5193
|
+
const { readGatewayStoreProfile } = await import("./admin-transport.mjs");
|
|
5194
|
+
read = await readGatewayStoreProfile({ subdomain: store.subdomain, credentials, fetchImpl });
|
|
5195
|
+
}
|
|
5196
|
+
// Recheck the original packet/spec identity after the network operation.
|
|
4970
5197
|
return specDeriveCommand(args, { store: { ...store, ...read, expected: { spec_path: preflight.spec_path, public_route_slug: preflight.public_route_slug } } });
|
|
4971
5198
|
}
|
|
4972
5199
|
|
|
@@ -5019,7 +5246,7 @@ export function specDeriveCommand(args, { store: storeRead = null } = {}) {
|
|
|
5019
5246
|
const unknown = Object.keys(args).filter((key) => key !== "_" && !SPEC_DERIVE_FLAGS.includes(key));
|
|
5020
5247
|
if (unknown.length) {
|
|
5021
5248
|
const valueHint = unknown.some((key) => key.includes("=")) ? " A flag takes its value as the next argument (--flag value), not --flag=value." : "";
|
|
5022
|
-
throw
|
|
5249
|
+
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
5250
|
}
|
|
5024
5251
|
// The store read is supplied by specDeriveFromStoreCommand; this function
|
|
5025
5252
|
// never touches the network itself, so a --from-store call that reaches it
|
|
@@ -5027,13 +5254,8 @@ export function specDeriveCommand(args, { store: storeRead = null } = {}) {
|
|
|
5027
5254
|
const storeFlags = parseSpecDeriveStoreFlags(args);
|
|
5028
5255
|
if (storeFlags && !storeRead) throw new Error("spec derive --from-store must be dispatched through specDeriveFromStoreCommand (no store read was supplied).");
|
|
5029
5256
|
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;
|
|
5257
|
+
const dryRun = isDryRun(args);
|
|
5258
|
+
if (args.report === true) throw refused("Missing value for --report");
|
|
5037
5259
|
const result = {
|
|
5038
5260
|
ok: false,
|
|
5039
5261
|
action: "spec derive",
|
|
@@ -5053,7 +5275,7 @@ export function specDeriveCommand(args, { store: storeRead = null } = {}) {
|
|
|
5053
5275
|
not_in_target: [],
|
|
5054
5276
|
stale_hints: [],
|
|
5055
5277
|
store: storeFlags
|
|
5056
|
-
? { subdomain: storeFlags.subdomain, admin_api: adminApiBaseForStore(storeFlags.subdomain), token_source: `env:${storeFlags.token_env}
|
|
5278
|
+
? { 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
5279
|
: null,
|
|
5058
5280
|
rebound: { build_context: null, assembly_report: null },
|
|
5059
5281
|
errors: [],
|
|
@@ -5236,8 +5458,8 @@ export function specDeriveCommand(args, { store: storeRead = null } = {}) {
|
|
|
5236
5458
|
return result;
|
|
5237
5459
|
}
|
|
5238
5460
|
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}` });
|
|
5461
|
+
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" };
|
|
5462
|
+
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
5463
|
return result;
|
|
5242
5464
|
}
|
|
5243
5465
|
const plan = planSpecDerive({ spec, entry, pageFiles, packetBindings, waivedGates, waiversUnknown, publicRouteSlug });
|
|
@@ -5440,7 +5662,7 @@ export function specDeriveTextLines(result) {
|
|
|
5440
5662
|
if (result.spec_path) lines.push(`Spec: ${singleLineField(result.spec_path)}`);
|
|
5441
5663
|
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
5664
|
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}` : ""})`);
|
|
5665
|
+
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
5666
|
}
|
|
5445
5667
|
if (result.errors?.length) {
|
|
5446
5668
|
lines.push("Errors:");
|
|
@@ -5481,9 +5703,9 @@ const PAGE_KIT_PARITY_FLAGS = Object.freeze(["packet", "json", "report"]);
|
|
|
5481
5703
|
export function pageKitParityCommand(args, options = {}) {
|
|
5482
5704
|
const unknown = Object.keys(args).filter((key) => key !== "_" && !PAGE_KIT_PARITY_FLAGS.includes(key));
|
|
5483
5705
|
if (unknown.length) {
|
|
5484
|
-
throw
|
|
5706
|
+
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
5707
|
}
|
|
5486
|
-
if (args.report === true) throw
|
|
5708
|
+
if (args.report === true) throw refused("Missing value for --report");
|
|
5487
5709
|
const packetPath = resolve(requireArg(args, "packet"));
|
|
5488
5710
|
const result = {
|
|
5489
5711
|
ok: false,
|
|
@@ -10784,7 +11006,7 @@ function resolveSkillInstallTargets(targetArg = null, platformArg = null) {
|
|
|
10784
11006
|
: SKILL_PLATFORMS.filter((platform) => platform.id === requested);
|
|
10785
11007
|
|
|
10786
11008
|
if (!selected.length) {
|
|
10787
|
-
throw
|
|
11009
|
+
throw refused(`Unknown --platform ${requested}. Use one of: ${skillPlatformHelp()}.`);
|
|
10788
11010
|
}
|
|
10789
11011
|
|
|
10790
11012
|
return selected.map((platform) => ({
|
|
@@ -10847,6 +11069,31 @@ function installSkills(targetArg = null, dryRun = false, platformArg = null) {
|
|
|
10847
11069
|
};
|
|
10848
11070
|
}
|
|
10849
11071
|
|
|
11072
|
+
// A platform directory counts as installed when a skill already sits under one
|
|
11073
|
+
// of the bundled names (current or not), or our own copy under a retired name. A
|
|
11074
|
+
// slot install-skills would only create says nothing about that platform, and
|
|
11075
|
+
// neither does a retired slot another skill occupies. A foreign skill under a
|
|
11076
|
+
// CURRENT bundled name reads as `updated` — install-skills would replace it —
|
|
11077
|
+
// so it does count; the refresh action is then what install-skills would do.
|
|
11078
|
+
const SKILL_ACTIONS_THAT_MARK_A_PLATFORM_INSTALLED = new Set(["unchanged", "updated", "retired"]);
|
|
11079
|
+
|
|
11080
|
+
export function scopeSkillStatusToInstalledPlatforms(status) {
|
|
11081
|
+
if (!Array.isArray(status?.targets)) return { status, scope: "requested", notInstalled: [] };
|
|
11082
|
+
const installedOn = (target) => (target.skills || []).some((skill) => SKILL_ACTIONS_THAT_MARK_A_PLATFORM_INSTALLED.has(skill?.action));
|
|
11083
|
+
const installed = status.targets.filter(installedOn);
|
|
11084
|
+
const describe = (target) => ({
|
|
11085
|
+
platform: target.platform,
|
|
11086
|
+
platform_label: target.platform_label,
|
|
11087
|
+
target_directory: target.target_directory,
|
|
11088
|
+
});
|
|
11089
|
+
if (!installed.length) return { status, scope: "no_platform_installed", notInstalled: status.targets.map(describe) };
|
|
11090
|
+
return {
|
|
11091
|
+
status: { ...status, targets: installed, skills: installed.flatMap((target) => target.skills) },
|
|
11092
|
+
scope: "installed_platforms",
|
|
11093
|
+
notInstalled: status.targets.filter((target) => !installedOn(target)).map(describe),
|
|
11094
|
+
};
|
|
11095
|
+
}
|
|
11096
|
+
|
|
10850
11097
|
const TOOLING_ACTIONABLE_SKILL_ACTIONS = new Set(["created", "updated", "retired"]);
|
|
10851
11098
|
const TOOLING_CLEAN_SKILL_ACTIONS = new Set(["unchanged"]);
|
|
10852
11099
|
|
|
@@ -10871,14 +11118,344 @@ function toolingSkillIdentity(skill) {
|
|
|
10871
11118
|
return `${prefix}${skill?.name || "unknown skill"}`;
|
|
10872
11119
|
}
|
|
10873
11120
|
|
|
11121
|
+
/**
|
|
11122
|
+
* `--skills-revision <value>`: the skills bundle identity an agent read, checked
|
|
11123
|
+
* against the bundle THIS CLI ships.
|
|
11124
|
+
*
|
|
11125
|
+
* The asymmetry is the whole point, and it is why the reported revision is named
|
|
11126
|
+
* `on_disk`. A skill's text is pulled into an agent's context once, at the start
|
|
11127
|
+
* of the task, and is never re-read; the CLI on disk, meanwhile, can be updated
|
|
11128
|
+
* underneath that session by an `npm install`, an `npx` cache refresh, or a
|
|
11129
|
+
* `git pull` in the checkout. So the only honest comparison is "what you are
|
|
11130
|
+
* still reading" against "what is installed right now", and the only honest
|
|
11131
|
+
* remedy for a mismatch is a fresh session — re-running the command cannot pull
|
|
11132
|
+
* the newer skill text into a context that already has the older one.
|
|
11133
|
+
*
|
|
11134
|
+
* The bundle spelling (`<package version>+skills.<n>`) is what every SKILL.md
|
|
11135
|
+
* states on its first body line. The `<skill-id>@<version>` spelling is a
|
|
11136
|
+
* fallback for an agent that carries only the frontmatter of the one skill it
|
|
11137
|
+
* loaded; it is checked against that skill's manifest entry. An id this bundle
|
|
11138
|
+
* does not ship is a mismatch, not a refusal: an agent quoting a skill that is
|
|
11139
|
+
* not here is reading text from some other bundle, which is exactly the
|
|
11140
|
+
* condition this flag exists to catch.
|
|
11141
|
+
*/
|
|
11142
|
+
export function parseSkillsRevisionArg(value) {
|
|
11143
|
+
const text = String(value).trim();
|
|
11144
|
+
const at = text.lastIndexOf("@");
|
|
11145
|
+
if (at > 0 && at < text.length - 1) {
|
|
11146
|
+
return { spelling: "skill", id: text.slice(0, at), version: text.slice(at + 1), requested: text };
|
|
11147
|
+
}
|
|
11148
|
+
return { spelling: "bundle", requested: text };
|
|
11149
|
+
}
|
|
11150
|
+
|
|
11151
|
+
export function evaluateSkillsRevision(value, manifest) {
|
|
11152
|
+
const onDisk = typeof manifest?.bundle_revision === "string" ? manifest.bundle_revision : null;
|
|
11153
|
+
if (value === undefined) {
|
|
11154
|
+
return {
|
|
11155
|
+
status: "unchecked",
|
|
11156
|
+
requested: null,
|
|
11157
|
+
spelling: null,
|
|
11158
|
+
on_disk: onDisk,
|
|
11159
|
+
on_disk_skill: null,
|
|
11160
|
+
message: `unchecked (on disk ${onDisk || "unknown"})`,
|
|
11161
|
+
};
|
|
11162
|
+
}
|
|
11163
|
+
const parsed = parseSkillsRevisionArg(value);
|
|
11164
|
+
if (parsed.spelling === "skill") {
|
|
11165
|
+
const entry = (manifest?.skills || []).find((skill) => skill?.id === parsed.id) || null;
|
|
11166
|
+
const onDiskSkill = entry ? { id: entry.id, version: entry.version ?? null } : null;
|
|
11167
|
+
const match = Boolean(entry) && entry.version === parsed.version;
|
|
11168
|
+
return {
|
|
11169
|
+
status: match ? "match" : "mismatch",
|
|
11170
|
+
requested: parsed.requested,
|
|
11171
|
+
spelling: "skill",
|
|
11172
|
+
on_disk: onDisk,
|
|
11173
|
+
on_disk_skill: onDiskSkill,
|
|
11174
|
+
message: match
|
|
11175
|
+
? `match (${parsed.requested}; bundle ${onDisk || "unknown"})`
|
|
11176
|
+
: `mismatch: loaded ${parsed.requested}, on disk ${
|
|
11177
|
+
onDiskSkill ? `${onDiskSkill.id}@${onDiskSkill.version}` : `no skill named ${parsed.id}`
|
|
11178
|
+
} (bundle ${onDisk || "unknown"}) — start a fresh session`,
|
|
11179
|
+
};
|
|
11180
|
+
}
|
|
11181
|
+
const match = Boolean(onDisk) && onDisk === parsed.requested;
|
|
11182
|
+
return {
|
|
11183
|
+
status: match ? "match" : "mismatch",
|
|
11184
|
+
requested: parsed.requested,
|
|
11185
|
+
spelling: "bundle",
|
|
11186
|
+
on_disk: onDisk,
|
|
11187
|
+
on_disk_skill: null,
|
|
11188
|
+
message: match
|
|
11189
|
+
? `match (${onDisk})`
|
|
11190
|
+
: `mismatch: loaded ${parsed.requested}, on disk ${onDisk || "unknown"} — start a fresh session`,
|
|
11191
|
+
};
|
|
11192
|
+
}
|
|
11193
|
+
|
|
11194
|
+
export function skillsRevisionTextLines(result) {
|
|
11195
|
+
const revision = result?.skills_revision;
|
|
11196
|
+
return revision ? [`Skills revision: ${revision.message}`] : [];
|
|
11197
|
+
}
|
|
11198
|
+
|
|
11199
|
+
// The pin checks (ADR 0002, campaigns-os#466): one executable per project. The
|
|
11200
|
+
// project pin (an exact devDependency or dependency on this package) comes
|
|
11201
|
+
// first, the kernel version the Build Packet records second. Only an exact
|
|
11202
|
+
// version is a pin — a range or tag names no one executable, so it is reported
|
|
11203
|
+
// as `range` and the project counts as unpinned.
|
|
11204
|
+
const PIN_PACKAGE_NAME = "@nextcommerce/campaigns-os";
|
|
11205
|
+
const EXACT_VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
11206
|
+
// npm reads `=1.2.3` and `v1.2.3` as the exact version 1.2.3.
|
|
11207
|
+
const EXACT_PIN_SPEC_RE = /^=?v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)$/;
|
|
11208
|
+
// peerDependencies and optionalDependencies install nothing this project runs.
|
|
11209
|
+
const PIN_KEYS = ["devDependencies", "dependencies"];
|
|
11210
|
+
const PIN_BLOCKING_STATUSES = new Set(["conflicting_pin", "stale_pin"]);
|
|
11211
|
+
|
|
11212
|
+
function exactPinVersion(spec) {
|
|
11213
|
+
return typeof spec === "string" ? EXACT_PIN_SPEC_RE.exec(spec)?.[1] ?? null : null;
|
|
11214
|
+
}
|
|
11215
|
+
|
|
11216
|
+
// An installed package's own manifest (node_modules/<name>/package.json or
|
|
11217
|
+
// node_modules/@<scope>/<name>/package.json) is never the project: run from
|
|
11218
|
+
// inside an install, the walk resolves the enclosing project as if the working
|
|
11219
|
+
// directory were that project. Any other manifest is a candidate, even one with
|
|
11220
|
+
// a node_modules segment higher up its path.
|
|
11221
|
+
function isInstalledPackageDir(dir) {
|
|
11222
|
+
const parent = dirname(dir);
|
|
11223
|
+
if (basename(parent) === "node_modules") return true;
|
|
11224
|
+
return basename(parent).startsWith("@") && basename(dirname(parent)) === "node_modules";
|
|
11225
|
+
}
|
|
11226
|
+
|
|
11227
|
+
function nearestPackageJson(startDir) {
|
|
11228
|
+
for (let dir = resolve(startDir); ; dir = dirname(dir)) {
|
|
11229
|
+
const candidate = join(dir, "package.json");
|
|
11230
|
+
if (!isInstalledPackageDir(dir) && existsSync(candidate) && statSync(candidate).isFile()) return candidate;
|
|
11231
|
+
if (dirname(dir) === dir) return null;
|
|
11232
|
+
}
|
|
11233
|
+
}
|
|
11234
|
+
|
|
11235
|
+
// npm's array form, or the `{ packages: [...] }` object form yarn also reads.
|
|
11236
|
+
function declaresWorkspaces(manifest) {
|
|
11237
|
+
const workspaces = manifest.workspaces;
|
|
11238
|
+
return Array.isArray(workspaces) || (isObject(workspaces) && Array.isArray(workspaces.packages));
|
|
11239
|
+
}
|
|
11240
|
+
|
|
11241
|
+
// An empty or whitespace-only spec pins nothing, so it counts as absent rather
|
|
11242
|
+
// than as a range.
|
|
11243
|
+
function pinSpecsIn(manifest) {
|
|
11244
|
+
return PIN_KEYS.flatMap((key) => {
|
|
11245
|
+
const spec = manifest?.[key]?.[PIN_PACKAGE_NAME];
|
|
11246
|
+
return typeof spec === "string" && spec.trim() ? [{ key, spec: spec.trim() }] : [];
|
|
11247
|
+
});
|
|
11248
|
+
}
|
|
11249
|
+
|
|
11250
|
+
// The project pin is the first exact spec on the walk up from the nearest
|
|
11251
|
+
// manifest, devDependencies before dependencies in each; a range is kept only
|
|
11252
|
+
// if nothing exact turns up. A manifest that names nothing is
|
|
11253
|
+
// neutral and walked through: it cannot supply a pin, so stopping there would
|
|
11254
|
+
// only hide one higher up. The walk ends after a workspace root, at the
|
|
11255
|
+
// filesystem root, and at a manifest it cannot read, with a warning, since
|
|
11256
|
+
// that one might have held the pin.
|
|
11257
|
+
function resolveProjectPin(packageJsonPath, warnings) {
|
|
11258
|
+
let range = null;
|
|
11259
|
+
for (let path = packageJsonPath; path; path = nearestPackageJson(dirname(dirname(path)))) {
|
|
11260
|
+
const problem = {};
|
|
11261
|
+
const manifest = readPinJson(path, problem);
|
|
11262
|
+
if (!manifest) {
|
|
11263
|
+
warnings.push(path === packageJsonPath
|
|
11264
|
+
? `Project pin unavailable: ${path} ${problem.reason}.`
|
|
11265
|
+
: `Project pin walk stopped at ${path}: it ${problem.reason}.`);
|
|
11266
|
+
break;
|
|
11267
|
+
}
|
|
11268
|
+
const specs = pinSpecsIn(manifest);
|
|
11269
|
+
const exact = specs.find(({ spec }) => exactPinVersion(spec));
|
|
11270
|
+
if (exact) return { projectSpec: exact.spec, projectManifest: path, projectKey: exact.key };
|
|
11271
|
+
if (!range && specs.length) range = { projectSpec: specs[0].spec, projectManifest: path, projectKey: specs[0].key };
|
|
11272
|
+
if (declaresWorkspaces(manifest) || dirname(dirname(path)) === dirname(path)) break;
|
|
11273
|
+
}
|
|
11274
|
+
return range || { projectSpec: null, projectManifest: packageJsonPath, projectKey: null };
|
|
11275
|
+
}
|
|
11276
|
+
|
|
11277
|
+
// A malformed file is a missing source plus a warning, never a crash: the pin
|
|
11278
|
+
// line is one report among several and must not take the status down with it.
|
|
11279
|
+
// A leading BOM is valid to npm, so it is valid here.
|
|
11280
|
+
function readPinJson(path, problem = {}) {
|
|
11281
|
+
let text;
|
|
11282
|
+
try {
|
|
11283
|
+
text = readFileSync(path, "utf8");
|
|
11284
|
+
} catch (error) {
|
|
11285
|
+
problem.reason = `could not be read (${error.code || error.message})`;
|
|
11286
|
+
return null;
|
|
11287
|
+
}
|
|
11288
|
+
let value;
|
|
11289
|
+
try {
|
|
11290
|
+
value = JSON.parse(text.replace(/^\uFEFF/, ""));
|
|
11291
|
+
} catch {
|
|
11292
|
+
problem.reason = "is not valid JSON";
|
|
11293
|
+
return null;
|
|
11294
|
+
}
|
|
11295
|
+
if (isObject(value)) return value;
|
|
11296
|
+
problem.reason = "is not a JSON object";
|
|
11297
|
+
return null;
|
|
11298
|
+
}
|
|
11299
|
+
|
|
11300
|
+
/** The project and packet sources `tooling status` compares, read from disk. */
|
|
11301
|
+
export function resolvePinSources(args, { cwd = process.cwd() } = {}) {
|
|
11302
|
+
const warnings = [];
|
|
11303
|
+
const explicitPacket = optionalString(args.packet);
|
|
11304
|
+
let packetPath = explicitPacket ? resolve(cwd, explicitPacket) : null;
|
|
11305
|
+
let packet = null;
|
|
11306
|
+
const packetProblem = {};
|
|
11307
|
+
if (packetPath) {
|
|
11308
|
+
if (!existsSync(packetPath)) throw refused(`Build Packet not found: ${packetPath}`);
|
|
11309
|
+
packet = readPinJson(packetPath, packetProblem);
|
|
11310
|
+
}
|
|
11311
|
+
// An explicit packet names its project: its target repo, whatever the cwd.
|
|
11312
|
+
const projectStart = packet ? targetRepoFor(packetPath, packet) : cwd;
|
|
11313
|
+
const packageJsonPath = nearestPackageJson(projectStart);
|
|
11314
|
+
if (!packetPath) {
|
|
11315
|
+
// The contracted home of the packet is the project root beside package.json
|
|
11316
|
+
// (prepare-build's default --out), the same place readback discovers it.
|
|
11317
|
+
packetPath = join(packageJsonPath ? dirname(packageJsonPath) : resolve(cwd), "campaign-runtime.build.json");
|
|
11318
|
+
if (existsSync(packetPath)) packet = readPinJson(packetPath, packetProblem);
|
|
11319
|
+
}
|
|
11320
|
+
|
|
11321
|
+
const { projectSpec, projectManifest, projectKey } = resolveProjectPin(packageJsonPath, warnings);
|
|
11322
|
+
|
|
11323
|
+
let packetVersion = null;
|
|
11324
|
+
let packetVersionIgnored = null;
|
|
11325
|
+
if (packet && Object.hasOwn(packet, "campaigns_os_version")) {
|
|
11326
|
+
const recorded = packet.campaigns_os_version;
|
|
11327
|
+
if (typeof recorded === "string" && EXACT_VERSION_RE.test(recorded)) {
|
|
11328
|
+
packetVersion = recorded;
|
|
11329
|
+
} else {
|
|
11330
|
+
// Kept verbatim (a non-string as its JSON text) so the Pin: line can say
|
|
11331
|
+
// the field was there and ignored, not that it was absent.
|
|
11332
|
+
packetVersionIgnored = typeof recorded === "string" ? recorded : JSON.stringify(recorded);
|
|
11333
|
+
warnings.push(`Packet campaigns_os_version ignored: ${JSON.stringify(recorded)} in ${packetPath} is not an exact version.`);
|
|
11334
|
+
}
|
|
11335
|
+
} else if (existsSync(packetPath) && !packet) {
|
|
11336
|
+
warnings.push(`Packet pin unavailable: ${packetPath} ${packetProblem.reason}.`);
|
|
11337
|
+
}
|
|
11338
|
+
return {
|
|
11339
|
+
packageJsonPath,
|
|
11340
|
+
projectSpec,
|
|
11341
|
+
projectManifest,
|
|
11342
|
+
projectKey,
|
|
11343
|
+
packetPath: packet ? packetPath : null,
|
|
11344
|
+
packetVersion,
|
|
11345
|
+
packetVersionIgnored,
|
|
11346
|
+
warnings,
|
|
11347
|
+
};
|
|
11348
|
+
}
|
|
11349
|
+
|
|
11350
|
+
/**
|
|
11351
|
+
* Pure: the pin status from the two sources and the running version. The
|
|
11352
|
+
* message names where each version it quotes was read: the key and manifest of
|
|
11353
|
+
* the project pin, the packet file of the recorded version.
|
|
11354
|
+
*/
|
|
11355
|
+
export function evaluatePin({ projectSpec = null, projectManifest = null, projectKey = null, packetVersion = null, packetVersionIgnored = null, packetPath = null, running, force = false }) {
|
|
11356
|
+
const projectVersion = exactPinVersion(projectSpec);
|
|
11357
|
+
const range = projectSpec != null && !projectVersion ? projectSpec : null;
|
|
11358
|
+
const source = projectVersion ? "project" : packetVersion ? "packet" : null;
|
|
11359
|
+
const version = projectVersion || packetVersion || null;
|
|
11360
|
+
const manifest = projectManifest || "package.json";
|
|
11361
|
+
const projectFrom = `${projectKey || "devDependencies"} in ${manifest}`;
|
|
11362
|
+
const packetFrom = `campaigns_os_version in ${packetPath || "the Build Packet"}`;
|
|
11363
|
+
const noPacket = packetVersionIgnored != null
|
|
11364
|
+
? `campaigns_os_version ${JSON.stringify(packetVersionIgnored)} in ${packetPath || "the Build Packet"} is not a bare x.y.z version and was ignored`
|
|
11365
|
+
: packetPath ? `no campaigns_os_version in ${packetPath}` : "no packet version";
|
|
11366
|
+
let status;
|
|
11367
|
+
let message;
|
|
11368
|
+
if (projectVersion && packetVersion && projectVersion !== packetVersion) {
|
|
11369
|
+
status = "conflicting_pin";
|
|
11370
|
+
message = `conflicting_pin — project pins ${projectVersion} (${projectFrom}), packet records ${packetVersion} (${packetFrom})`;
|
|
11371
|
+
} else if (!version) {
|
|
11372
|
+
status = "unpinned";
|
|
11373
|
+
const project = range != null
|
|
11374
|
+
? `project range ${range || '""'} (${projectFrom}) is not an exact version`
|
|
11375
|
+
: projectManifest ? `no project pin in ${projectManifest}` : "no package.json found";
|
|
11376
|
+
message = `unpinned (${project}; ${noPacket})`;
|
|
11377
|
+
} else if (version !== running) {
|
|
11378
|
+
status = "stale_pin";
|
|
11379
|
+
message = `stale_pin — ${source === "project" ? `project pins ${version} (${projectFrom})` : `packet records ${version} (${packetFrom})`}, running ${running}`;
|
|
11380
|
+
} else {
|
|
11381
|
+
status = "match";
|
|
11382
|
+
message = `match (${version} — ${source === "project" ? projectFrom : packetFrom})`;
|
|
11383
|
+
}
|
|
11384
|
+
const forced = force && PIN_BLOCKING_STATUSES.has(status);
|
|
11385
|
+
return {
|
|
11386
|
+
source,
|
|
11387
|
+
version,
|
|
11388
|
+
running,
|
|
11389
|
+
status,
|
|
11390
|
+
range,
|
|
11391
|
+
packet_version: packetVersion,
|
|
11392
|
+
packet_version_ignored: packetVersionIgnored,
|
|
11393
|
+
project_version: projectVersion,
|
|
11394
|
+
project_manifest: projectManifest,
|
|
11395
|
+
project_key: projectKey,
|
|
11396
|
+
forced,
|
|
11397
|
+
message: forced ? `${message} (overridden by --force)` : message,
|
|
11398
|
+
};
|
|
11399
|
+
}
|
|
11400
|
+
|
|
11401
|
+
// Each line names the manifest and key the pin (or range) was read from; with
|
|
11402
|
+
// neither, the nearest manifest and devDependencies, where the ADR puts a pin.
|
|
11403
|
+
function pinAction(pin, sources) {
|
|
11404
|
+
const manifest = pin.project_manifest || "the project's package.json";
|
|
11405
|
+
const key = pin.project_key || "devDependencies";
|
|
11406
|
+
const packet = sources.packetPath || "the Build Packet";
|
|
11407
|
+
if (pin.status === "conflicting_pin") {
|
|
11408
|
+
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).`;
|
|
11409
|
+
}
|
|
11410
|
+
if (pin.source === "project") {
|
|
11411
|
+
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).`;
|
|
11412
|
+
}
|
|
11413
|
+
const pinStep = pin.project_key
|
|
11414
|
+
? `set ${key}["${PIN_PACKAGE_NAME}"] in ${manifest} to ${pin.running} (it holds the range ${JSON.stringify(pin.range)})`
|
|
11415
|
+
: `add "${PIN_PACKAGE_NAME}": "${pin.running}" to ${key} in ${manifest}`;
|
|
11416
|
+
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).`;
|
|
11417
|
+
}
|
|
11418
|
+
|
|
11419
|
+
export function pinTextLines(result) {
|
|
11420
|
+
return result?.pin ? [`Pin: ${result.pin.message}`] : [];
|
|
11421
|
+
}
|
|
11422
|
+
|
|
10874
11423
|
function toolingCommand(args) {
|
|
10875
11424
|
const action = args._[1] || "status";
|
|
10876
|
-
if (action !== "status") throw
|
|
10877
|
-
if (args.target === true) throw
|
|
10878
|
-
if (args.platform === true) throw
|
|
11425
|
+
if (action !== "status") throw refused(`Unknown tooling command: ${action}`);
|
|
11426
|
+
if (args.target === true) throw refused("Missing value for --target");
|
|
11427
|
+
if (args.platform === true) throw refused("Missing value for --platform");
|
|
11428
|
+
if (args["skills-revision"] === true) {
|
|
11429
|
+
// The example is the bundle this CLI ships, read from skills.json, so the
|
|
11430
|
+
// refusal never teaches a revision that has since moved.
|
|
11431
|
+
const shipped = readJson(join(ROOT, "skills.json")).bundle_revision;
|
|
11432
|
+
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>.`);
|
|
11433
|
+
}
|
|
11434
|
+
// Bare, like --dry-run: the shared parser would read `--force true` as a
|
|
11435
|
+
// value, and an override must never hinge on how a token happened to parse.
|
|
11436
|
+
if (Object.hasOwn(args, "force") && args.force !== true) {
|
|
11437
|
+
throw refused(`--force takes no value (got ${JSON.stringify(args.force)}); write \`--force\` on its own.`);
|
|
11438
|
+
}
|
|
11439
|
+
// The parser keeps `--no-force` as its own key, so without this it would
|
|
11440
|
+
// run as if unsaid and the operator would never learn it did nothing.
|
|
11441
|
+
if (Object.hasOwn(args, "no-force")) {
|
|
11442
|
+
throw refused("--no-force is not a flag of tooling status; --force is bare and off by default");
|
|
11443
|
+
}
|
|
11444
|
+
if (args.packet === true) throw refused("Missing value for --packet");
|
|
11445
|
+
const pinSources = resolvePinSources(args);
|
|
10879
11446
|
|
|
10880
11447
|
const pkg = readJson(join(ROOT, "package.json"));
|
|
10881
|
-
|
|
11448
|
+
// An explicit --target or --platform (`all` included) is checked as asked.
|
|
11449
|
+
// Without either, only the platform directories that already hold a
|
|
11450
|
+
// Campaigns OS skill are held to this bundle: the documented install is one
|
|
11451
|
+
// platform, and reading the other two as stale told that operator to install
|
|
11452
|
+
// everywhere and exit 2 on a correct setup.
|
|
11453
|
+
const explicitSkillScope = isNonEmptyString(args.target) || isNonEmptyString(args.platform);
|
|
11454
|
+
const allSkillStatus = installSkills(args.target, true, args.platform || "all");
|
|
11455
|
+
const skillScope = explicitSkillScope
|
|
11456
|
+
? { status: allSkillStatus, scope: "requested", notInstalled: [] }
|
|
11457
|
+
: scopeSkillStatusToInstalledPlatforms(allSkillStatus);
|
|
11458
|
+
const skillStatus = skillScope.status;
|
|
10882
11459
|
const skillActions = classifyToolingSkillActions(skillStatus.skills || []);
|
|
10883
11460
|
const staleSkills = skillActions.actionable;
|
|
10884
11461
|
const install = localInstallStatus(ROOT, pkg);
|
|
@@ -10950,9 +11527,30 @@ function toolingCommand(args) {
|
|
|
10950
11527
|
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
11528
|
}
|
|
10952
11529
|
|
|
10953
|
-
if (
|
|
10954
|
-
const
|
|
10955
|
-
|
|
11530
|
+
if (skillScope.scope === "installed_platforms" && skillScope.notInstalled.length) {
|
|
11531
|
+
const checked = skillStatus.targets.map((target) => target.platform_label).join(", ");
|
|
11532
|
+
const skipped = skillScope.notInstalled.map((target) => target.platform_label);
|
|
11533
|
+
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).`);
|
|
11534
|
+
}
|
|
11535
|
+
|
|
11536
|
+
if (skillScope.scope === "no_platform_installed") {
|
|
11537
|
+
// Nothing to refresh: the documented install is one platform, the
|
|
11538
|
+
// harness in use, so name the choice rather than installing everywhere.
|
|
11539
|
+
// The command ends its own sentence and is runnable as printed (Claude
|
|
11540
|
+
// Code, the documented install). The other platforms follow in a separate
|
|
11541
|
+
// sentence of prose: no `<a|b>` template or parenthesis a shell would read
|
|
11542
|
+
// as a redirect or a subshell if the command were copied with it.
|
|
11543
|
+
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.`);
|
|
11544
|
+
} else if (staleSkills.length) {
|
|
11545
|
+
const stalePlatforms = SKILL_PLATFORMS.map((platform) => platform.id)
|
|
11546
|
+
.filter((id) => staleSkills.some((skill) => skill.platform === id));
|
|
11547
|
+
const invocations = args.target
|
|
11548
|
+
? [["--target", args.target]]
|
|
11549
|
+
: skillScope.scope === "installed_platforms" && stalePlatforms.length < SKILL_PLATFORMS.length
|
|
11550
|
+
? stalePlatforms.map((platform) => ["--platform", platform])
|
|
11551
|
+
: [["--platform", args.platform || "all"]];
|
|
11552
|
+
const commands = invocations.map((skillArgs) => `${cli.invocation_prefix} install-skills ${skillArgs.join(" ")}`);
|
|
11553
|
+
actions.push(`Refresh installed skills: ${commands.join(" and ")}. Restart local agent sessions afterwards.`);
|
|
10956
11554
|
}
|
|
10957
11555
|
|
|
10958
11556
|
if (install.mode === "checkout" && cli.global_binary.status === "not_found") {
|
|
@@ -10960,8 +11558,8 @@ function toolingCommand(args) {
|
|
|
10960
11558
|
} else if (install.mode === "node_modules" && cli.global_binary.status !== "found") {
|
|
10961
11559
|
// A consumer install runs through npm's bin resolution; no PATH ritual.
|
|
10962
11560
|
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
|
|
11561
|
+
? `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.`
|
|
11562
|
+
: `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
11563
|
} else if (install.mode !== "checkout" && install.mode !== "npx_cache" && cli.global_binary.status === "not_found") {
|
|
10966
11564
|
warnings.push(`campaigns-os is not on PATH; call node ${cli.local_bin} directly.`);
|
|
10967
11565
|
} else if (install.mode !== "checkout" && cli.global_binary.status === "found_other_install") {
|
|
@@ -10980,11 +11578,43 @@ function toolingCommand(args) {
|
|
|
10980
11578
|
warnings.push("This checkout has uncommitted changes; verify they are intentional before publishing or comparing freshness.");
|
|
10981
11579
|
}
|
|
10982
11580
|
|
|
11581
|
+
// The manifest THIS CLI ships, read from its own package root — never from the
|
|
11582
|
+
// working directory, which may be a campaign repo with no skills.json at all.
|
|
11583
|
+
const skillsRevision = evaluateSkillsRevision(args["skills-revision"], readJsonIfExists(join(ROOT, "skills.json")) || {});
|
|
11584
|
+
if (skillsRevision.status === "mismatch") {
|
|
11585
|
+
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.`);
|
|
11586
|
+
}
|
|
11587
|
+
|
|
11588
|
+
// The CLI's own package.json is the running version: the executable this
|
|
11589
|
+
// command is, not whichever one the project would have resolved.
|
|
11590
|
+
const pin = evaluatePin({
|
|
11591
|
+
projectSpec: pinSources.projectSpec,
|
|
11592
|
+
projectManifest: pinSources.projectManifest,
|
|
11593
|
+
projectKey: pinSources.projectKey,
|
|
11594
|
+
packetVersion: pinSources.packetVersion,
|
|
11595
|
+
packetVersionIgnored: pinSources.packetVersionIgnored,
|
|
11596
|
+
packetPath: pinSources.packetPath,
|
|
11597
|
+
running: pkg.version,
|
|
11598
|
+
force: args.force === true,
|
|
11599
|
+
});
|
|
11600
|
+
warnings.push(...pinSources.warnings);
|
|
11601
|
+
const pinBlocks = PIN_BLOCKING_STATUSES.has(pin.status) && !pin.forced;
|
|
11602
|
+
if (PIN_BLOCKING_STATUSES.has(pin.status)) {
|
|
11603
|
+
(pin.forced ? warnings : actions).push(pin.forced
|
|
11604
|
+
? `Pin ${pin.status} overridden by --force; this run proceeds on ${pin.running}.`
|
|
11605
|
+
: pinAction(pin, pinSources));
|
|
11606
|
+
}
|
|
11607
|
+
|
|
10983
11608
|
const gitBlocks = git.status === "ok" && Number.isFinite(git.behind) && git.behind > 0;
|
|
10984
|
-
const ok = !gitBlocks && staleSkills.length === 0;
|
|
11609
|
+
const ok = !gitBlocks && staleSkills.length === 0 && skillsRevision.status !== "mismatch" && !pinBlocks;
|
|
10985
11610
|
return {
|
|
10986
11611
|
ok,
|
|
10987
11612
|
status: ok ? "ready" : "attention_required",
|
|
11613
|
+
// A bare status string, so a consumer can branch on it without reaching into
|
|
11614
|
+
// an object; the detail sits beside it under skills_revision.
|
|
11615
|
+
revision_check: skillsRevision.status,
|
|
11616
|
+
skills_revision: skillsRevision,
|
|
11617
|
+
pin,
|
|
10988
11618
|
install,
|
|
10989
11619
|
package: packageStatus,
|
|
10990
11620
|
git,
|
|
@@ -10992,6 +11622,12 @@ function toolingCommand(args) {
|
|
|
10992
11622
|
skills: {
|
|
10993
11623
|
ok: staleSkills.length === 0,
|
|
10994
11624
|
stale_count: staleSkills.length,
|
|
11625
|
+
// requested: --target/--platform named the scope. installed_platforms:
|
|
11626
|
+
// only platforms with Campaigns OS skills installed were checked, and
|
|
11627
|
+
// not_installed_platforms lists the rest. no_platform_installed: none
|
|
11628
|
+
// had any, so every platform is listed there and was checked.
|
|
11629
|
+
scope: skillScope.scope,
|
|
11630
|
+
not_installed_platforms: skillScope.notInstalled,
|
|
10995
11631
|
status: skillStatus,
|
|
10996
11632
|
},
|
|
10997
11633
|
ready,
|
|
@@ -11000,21 +11636,37 @@ function toolingCommand(args) {
|
|
|
11000
11636
|
};
|
|
11001
11637
|
}
|
|
11002
11638
|
|
|
11639
|
+
export async function toolingStatusCommand(args, options = {}) {
|
|
11640
|
+
const result = toolingCommand(args);
|
|
11641
|
+
const { gatewayLoginStatus } = await import("./admin-transport.mjs");
|
|
11642
|
+
result.gateway_login = await gatewayLoginStatus(options);
|
|
11643
|
+
const auth = result.gateway_login;
|
|
11644
|
+
if (!auth.accounts.length) result.warnings.push(auth.state === "unavailable"
|
|
11645
|
+
? "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."
|
|
11646
|
+
: `Gateway login: ${auth.state}. Use ${result.cli.invocation_prefix} login --store <subdomain>.`);
|
|
11647
|
+
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).`);
|
|
11648
|
+
return result;
|
|
11649
|
+
}
|
|
11650
|
+
|
|
11003
11651
|
export function toolingDiagnose(args, { runTooling = toolingCommand, runDoctor = doctorCommand } = {}) {
|
|
11004
11652
|
let tooling = null;
|
|
11005
11653
|
let doctor = null;
|
|
11006
11654
|
let inspectionFailed = false;
|
|
11007
|
-
const platform = args.platform || "all";
|
|
11008
11655
|
// Inputs are used only by local producers, never echoed, and mutation flags
|
|
11009
11656
|
// are not forwarded. Even an exception's message may contain a secret path.
|
|
11657
|
+
// An unnamed platform is not forwarded, so status checks only the platforms
|
|
11658
|
+
// that hold Campaigns OS skills, as it does when run directly.
|
|
11010
11659
|
try {
|
|
11011
|
-
tooling = runTooling({ _: ["tooling", "status"], platform, ...(typeof args.target === "string" ? { target: args.target } : {}) });
|
|
11660
|
+
tooling = runTooling({ _: ["tooling", "status"], ...(args.platform ? { platform: args.platform } : {}), ...(typeof args.target === "string" ? { target: args.target } : {}) });
|
|
11012
11661
|
} catch { /* unavailable, no raw producer exception in a support export */ }
|
|
11013
11662
|
if (args.packet !== undefined) {
|
|
11014
11663
|
try {
|
|
11015
11664
|
doctor = runDoctor({ packet: args.packet, "no-write": true, ...(typeof args.context === "string" ? { context: args.context } : {}), ...(typeof args.report === "string" ? { report: args.report } : {}) });
|
|
11016
11665
|
} catch { inspectionFailed = true; }
|
|
11017
11666
|
}
|
|
11667
|
+
// `installed` only when status actually narrowed to installed platforms; an
|
|
11668
|
+
// unnamed platform that checked every one (nothing installed) stays `all`.
|
|
11669
|
+
const platform = args.platform || (tooling?.skills?.scope === "installed_platforms" ? "installed" : "all");
|
|
11018
11670
|
return diagnosticExport({ tooling, doctor, platform, inspectionFailed });
|
|
11019
11671
|
}
|
|
11020
11672
|
|
|
@@ -11495,7 +12147,7 @@ async function findingsCommand(args, ambient = null) {
|
|
|
11495
12147
|
if (sub === "harvest") return findingsHarvest(args, ambient);
|
|
11496
12148
|
if (sub === "list") return findingsList(args, ambient);
|
|
11497
12149
|
if (sub === "export") return findingsExport(args, ambient);
|
|
11498
|
-
throw
|
|
12150
|
+
throw refused(`Unknown findings subcommand "${sub}". Use: add | harvest | list | export.`);
|
|
11499
12151
|
}
|
|
11500
12152
|
|
|
11501
12153
|
function resolveFindingsJournalPath(args, ambient = null) {
|
|
@@ -11539,7 +12191,7 @@ async function findingsAdd(args, ambient = null) {
|
|
|
11539
12191
|
summary = answers.summary;
|
|
11540
12192
|
details = answers.details;
|
|
11541
12193
|
} else {
|
|
11542
|
-
throw
|
|
12194
|
+
throw refused(
|
|
11543
12195
|
`findings add is missing required flags: ${missing.join(", ")}. `
|
|
11544
12196
|
+ `Provide them as flags (flags-first for agents/CI), e.g. `
|
|
11545
12197
|
+ `--stage ${FINDING_STAGES[0]} --kind ${FINDING_KINDS[0]} --summary "..."`,
|
|
@@ -11773,7 +12425,7 @@ export async function runSessionCommand(args, ambient = null, sessionHolder = nu
|
|
|
11773
12425
|
if (sub === "start") return runSessionStart(args);
|
|
11774
12426
|
if (sub === "status") return runSessionStatus(args, ambient);
|
|
11775
12427
|
if (sub === "end") return runSessionEnd(args, ambient, sessionHolder);
|
|
11776
|
-
throw
|
|
12428
|
+
throw refused(`Unknown run subcommand "${sub}". Use: start | end | status.`);
|
|
11777
12429
|
}
|
|
11778
12430
|
|
|
11779
12431
|
function writeRunSessionResult(result, args, exitCode) {
|
|
@@ -11987,10 +12639,14 @@ async function runSessionEnd(args, ambient = null, sessionHolder = null) {
|
|
|
11987
12639
|
// `qa run`'s --base-url, --browser or --test-order say nothing about the
|
|
11988
12640
|
// record — and run-record stamps the flag NAMES it was given into the Run
|
|
11989
12641
|
// Record's argv_shape, so carrying them over would file them as run-record's.
|
|
12642
|
+
// `dry-run` is on the list for `run end --dry-run`, the one closer whose
|
|
12643
|
+
// invoking command implements the flag; a closer invoked by a command that
|
|
12644
|
+
// does not (the QA auto-end) drops it from extraArgs before calling — see
|
|
12645
|
+
// DRY_RUN_COMMANDS and autoEndRunSessionAfterTerminalQa.
|
|
11990
12646
|
const RUN_RECORD_INHERITABLE_FLAGS = Object.freeze([
|
|
11991
12647
|
"context", "report", "qa-verdict", "journal", "surfaces", "primary-surface", "surface-confidence",
|
|
11992
12648
|
"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",
|
|
12649
|
+
"no-remit", "no-write", "proxy-base", "dry-run", "json",
|
|
11994
12650
|
]);
|
|
11995
12651
|
|
|
11996
12652
|
// The run-record argv that closes `session`: its run_id and journal, the
|
|
@@ -12019,7 +12675,10 @@ async function closeRunSession(found, { packet, extraArgs = {}, silent = false,
|
|
|
12019
12675
|
const endArgs = runSessionEndArgs(found.session, packet, extraArgs);
|
|
12020
12676
|
try {
|
|
12021
12677
|
const summary = await runRecordCommand(endArgs, found, { silent, promptForConsent });
|
|
12022
|
-
|
|
12678
|
+
// Clearing the session is a write like any other, so a closer carrying
|
|
12679
|
+
// --dry-run leaves it open: the operator sees the record the close would
|
|
12680
|
+
// assemble and can still close for real afterwards.
|
|
12681
|
+
if (endArgs["dry-run"] !== true) clearRunSession(found.path);
|
|
12023
12682
|
return summary;
|
|
12024
12683
|
} catch (error) {
|
|
12025
12684
|
if (!onError) throw error;
|
|
@@ -12041,11 +12700,34 @@ async function closeRunSession(found, { packet, extraArgs = {}, silent = false,
|
|
|
12041
12700
|
// forms. `run status` never sweeps — it is read-only.
|
|
12042
12701
|
// Best-effort throughout: a closeout failure clears the file and says so on
|
|
12043
12702
|
// stderr; it never blocks the command that triggered it.
|
|
12703
|
+
//
|
|
12704
|
+
// The sweep is an effect of the command that triggers it, and it runs BEFORE
|
|
12705
|
+
// dispatch — so it happens even when the argv that follows is refused. That is
|
|
12706
|
+
// deliberate (the stale session at the target is closed out either way), but it
|
|
12707
|
+
// makes the sweep the one place where `--no-write` could still write: it
|
|
12708
|
+
// assembles a Run Record and removes the session file. `--no-write` writes
|
|
12709
|
+
// nothing, the closeout included; see the guard below.
|
|
12044
12710
|
const STALE_SWEEP_TARGET_COMMANDS = new Set(["start", "prepare-build", "build"]);
|
|
12045
12711
|
|
|
12046
12712
|
async function closeOutStaleRunSessions(command, args) {
|
|
12047
12713
|
// A command that opted out of sessions altogether must not sweep either.
|
|
12048
12714
|
if (args["no-run-session"] === true) return [];
|
|
12715
|
+
// --no-write leaves the tree byte-identical. Inheriting the flag into the
|
|
12716
|
+
// closeout was not enough: it suppressed the Run Record but clearRunSession
|
|
12717
|
+
// still deleted the session file, so `--no-write` moved bytes. Skip the
|
|
12718
|
+
// sweep entirely instead — the stale session stays for the next run that
|
|
12719
|
+
// does write.
|
|
12720
|
+
if (args["no-write"] === true) return [];
|
|
12721
|
+
// Nor may a dry run sweep. The closeout writes a Run Record, deletes the
|
|
12722
|
+
// session file and (under consent) sends a remit — every effect --dry-run
|
|
12723
|
+
// promises not to have. The sweep runs BEFORE dispatch, so it was doing all
|
|
12724
|
+
// three for commands that implement the flag: `run end --dry-run --json`
|
|
12725
|
+
// over an aged session exited 0, wrote a record, POSTed once and removed the
|
|
12726
|
+
// session. --dry-run means "show me, do nothing"; the stale session simply
|
|
12727
|
+
// stays stale until a real invocation closes it. Gated on the same predicate
|
|
12728
|
+
// persistLifecycleIfRequested uses, so a stray --dry-run on a command that
|
|
12729
|
+
// does not implement it changes nothing here either.
|
|
12730
|
+
if (args["dry-run"] === true && commandImplementsDryRun(command, args)) return [];
|
|
12049
12731
|
const roots = [];
|
|
12050
12732
|
if (STALE_SWEEP_TARGET_COMMANDS.has(command) && optionalString(args.target)) roots.push(resolve(args.target));
|
|
12051
12733
|
if (command === "run" && (args._[1] === "start" || args._[1] === "end")) {
|
|
@@ -12062,8 +12744,11 @@ async function closeOutStaleRunSessions(command, args) {
|
|
|
12062
12744
|
}
|
|
12063
12745
|
}
|
|
12064
12746
|
// The closeout inherits the invoking command's remit controls: an explicit
|
|
12065
|
-
// --no-remit
|
|
12066
|
-
//
|
|
12747
|
+
// --no-remit stays an opt-out, and a run pointed at a custom --proxy-base
|
|
12748
|
+
// never remits the stale record to the canonical endpoint. --no-write is
|
|
12749
|
+
// carried too, though the guard above means it never arrives true: if the
|
|
12750
|
+
// sweep ever becomes conditional rather than skipped, the closeout must
|
|
12751
|
+
// still see it.
|
|
12067
12752
|
const inherited = {};
|
|
12068
12753
|
for (const flag of ["no-remit", "no-write", "proxy-base"]) {
|
|
12069
12754
|
if (args[flag] !== undefined) inherited[flag] = args[flag];
|
|
@@ -12207,6 +12892,10 @@ export function describeCampaignKeyRejection(rejected) {
|
|
|
12207
12892
|
// docs/workflow-findings-sidecar.md.
|
|
12208
12893
|
async function runRecordCommand(args, ambient = null, { silent = false, promptForConsent = true } = {}) {
|
|
12209
12894
|
const packetPath = resolve(requireArg(args, "packet"));
|
|
12895
|
+
// --dry-run assembles the record and shows it, then writes and sends
|
|
12896
|
+
// nothing. It differs from --no-write, which skips the assembly's reads
|
|
12897
|
+
// as well; combining the two is allowed and still writes nothing.
|
|
12898
|
+
const dryRun = isDryRun(args);
|
|
12210
12899
|
const parsedSurfaces = parseRunRecordSurfaces(args.surfaces);
|
|
12211
12900
|
const packet = readJson(packetPath);
|
|
12212
12901
|
const explicitTargetRepo = resolveFromFile(packetPath, packet.assembly?.target_repo);
|
|
@@ -12307,6 +12996,7 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
|
|
|
12307
12996
|
run_id_source: runIdSource,
|
|
12308
12997
|
records,
|
|
12309
12998
|
remit: { result: null, http_status: null, base_kind: null, sent: false, preserved: false },
|
|
12999
|
+
...(dryRun ? { dry_run: true, would_write: null, would_remit: null } : {}),
|
|
12310
13000
|
};
|
|
12311
13001
|
if (silent) return summary;
|
|
12312
13002
|
if (args.json) {
|
|
@@ -12373,7 +13063,11 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
|
|
|
12373
13063
|
}
|
|
12374
13064
|
if (existsSync(journalPath)) artifacts.push(runRecordArtifactRef("findings_journal", journalPath, WORKFLOW_FINDING_SCHEMA, baseDir));
|
|
12375
13065
|
|
|
12376
|
-
|
|
13066
|
+
// What a real run of this command line would write, and what this one does:
|
|
13067
|
+
// a dry run assembles and validates the record a real run would write
|
|
13068
|
+
// (assertRunRecordValid), and stops at the write itself (writeRunRecord).
|
|
13069
|
+
const wouldWrite = args["no-write"] !== true;
|
|
13070
|
+
const write = wouldWrite && !dryRun;
|
|
12377
13071
|
// The verdict publish outcome this record carries: the session's attempt
|
|
12378
13072
|
// for the verdict being recorded (the auto-end and `run end` both close
|
|
12379
13073
|
// through here with the session still ambient), else the newest attempt
|
|
@@ -12388,8 +13082,10 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
|
|
|
12388
13082
|
// refuses a second send, so a record it already has is final: it is neither
|
|
12389
13083
|
// re-sent nor rewritten here. A prior send that did not land (failed,
|
|
12390
13084
|
// pending) is retried when this run may send, and kept as it stands when it
|
|
12391
|
-
// may not. A
|
|
12392
|
-
|
|
13085
|
+
// may not. A pure --no-write run reads nothing: it writes and sends nothing.
|
|
13086
|
+
// A --dry-run does read it, because what a real run would do here is the
|
|
13087
|
+
// very thing the dry run is being asked to report.
|
|
13088
|
+
const prior = write || dryRun ? readPriorRunRecord(runId, baseDir) : null;
|
|
12393
13089
|
const priorRemit = priorRemitOutcome(prior?.record);
|
|
12394
13090
|
const storedRemotely = priorRemit?.state === "ok";
|
|
12395
13091
|
// A pure local-inspection run (--no-write), an explicit --no-remit, or a
|
|
@@ -12410,6 +13106,16 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
|
|
|
12410
13106
|
announceDefaultOnTelemetry(consent.scope || proxyBase);
|
|
12411
13107
|
}
|
|
12412
13108
|
|
|
13109
|
+
// Under --dry-run the remit is disabled by construction (write is false), so
|
|
13110
|
+
// `remitDisabled` cannot say whether a real run would have sent. This does:
|
|
13111
|
+
// the same three stoppers minus the dry run itself, against the consent this
|
|
13112
|
+
// machine actually resolved.
|
|
13113
|
+
const wouldRemit = dryRun
|
|
13114
|
+
&& args["no-remit"] !== true
|
|
13115
|
+
&& args["no-write"] !== true
|
|
13116
|
+
&& !storedRemotely
|
|
13117
|
+
&& consent.state === "on";
|
|
13118
|
+
|
|
12413
13119
|
// A publish the record already says landed is never downgraded by a
|
|
12414
13120
|
// reassembly: the prior ok block wins over a session attempt that did not
|
|
12415
13121
|
// land, mirroring the remit carry-forward below.
|
|
@@ -12446,9 +13152,6 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
|
|
|
12446
13152
|
agentUsage,
|
|
12447
13153
|
});
|
|
12448
13154
|
|
|
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
13155
|
const shouldAttemptRemit = !remitDisabled && consent.state === "on";
|
|
12453
13156
|
const remitBaseKind = describeRemitBaseKind(proxyBase);
|
|
12454
13157
|
|
|
@@ -12467,6 +13170,9 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
|
|
|
12467
13170
|
record: prior.record,
|
|
12468
13171
|
run_id_source: runIdSource,
|
|
12469
13172
|
remit: { result: REMIT_RESULTS.not_contacted, http_status: null, base_kind: null, sent: false, preserved: true },
|
|
13173
|
+
// A record the receiver already holds is final: a real run of this same
|
|
13174
|
+
// command line would write nothing and send nothing either.
|
|
13175
|
+
...(dryRun ? { dry_run: true, would_write: null, would_remit: null } : {}),
|
|
12470
13176
|
};
|
|
12471
13177
|
if (silent) return summary;
|
|
12472
13178
|
if (args.json) {
|
|
@@ -12504,7 +13210,11 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
|
|
|
12504
13210
|
record.remit_base_kind = carriedForward.base_kind;
|
|
12505
13211
|
}
|
|
12506
13212
|
|
|
12507
|
-
|
|
13213
|
+
// The record is validated whenever a real run of this command line would
|
|
13214
|
+
// write one — under --dry-run too — and always BEFORE the remit below, so an
|
|
13215
|
+
// invalid record refuses ahead of any send on both paths. The write itself is
|
|
13216
|
+
// separate and happens after the remit; validating here does not write.
|
|
13217
|
+
if (wouldWrite) assertRunRecordValid(record);
|
|
12508
13218
|
|
|
12509
13219
|
// Remit is consent-gated, non-fatal, bounded, and keyed on run_id — the
|
|
12510
13220
|
// receiver holds one record per id and refuses a second POST for one it
|
|
@@ -12514,7 +13224,10 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
|
|
|
12514
13224
|
// The key is only resolved when a send will actually be attempted: under
|
|
12515
13225
|
// consent-off or --no-remit nothing goes out, so nothing is read and nothing
|
|
12516
13226
|
// is said about a credential.
|
|
12517
|
-
|
|
13227
|
+
// A dry run resolves it too, and only when a real run would have: the
|
|
13228
|
+
// destination gate below names the credential that would travel, and the
|
|
13229
|
+
// preview must name the one the real send would.
|
|
13230
|
+
const keySource = shouldAttemptRemit || wouldRemit ? resolveCampaignsApiKeySource(packet, packetPath, process.env) : { key: null, rejected: null };
|
|
12518
13231
|
const campaignKey = keySource.key;
|
|
12519
13232
|
// A refused key is not a missing key. Say so on stderr, naming the source
|
|
12520
13233
|
// and not the value, so the operator fixes the credential instead of reading
|
|
@@ -12524,7 +13237,30 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
|
|
|
12524
13237
|
// send's outcome, which is only known below; the credential itself never
|
|
12525
13238
|
// leaves the machine.
|
|
12526
13239
|
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
|
|
13240
|
+
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`);
|
|
13241
|
+
|
|
13242
|
+
// The transport's own preconditions, run for a dry run as well: `remit`
|
|
13243
|
+
// demands a fetch to send with, and refuses a base that is not https (nor a
|
|
13244
|
+
// loopback host), BEFORE it opens a socket — so a send the real run could
|
|
13245
|
+
// never have made must not be previewed as one it would post. Same
|
|
13246
|
+
// functions, in the transport's order, with the same label and the same
|
|
13247
|
+
// credential wording the remit below hands them. The remit rail is non-fatal
|
|
13248
|
+
// by contract — the real run classifies either refusal as a failed remit and
|
|
13249
|
+
// still exits 0 — so the dry run reports it on the envelope and exits 0 too.
|
|
13250
|
+
let wouldRemitEndpoint = null;
|
|
13251
|
+
let wouldRemitRefusal = null;
|
|
13252
|
+
if (wouldRemit) {
|
|
13253
|
+
try {
|
|
13254
|
+
assertFetchAvailable(globalThis.fetch);
|
|
13255
|
+
const { base } = assertSecureProxyBase(proxyBase, {
|
|
13256
|
+
label: "Run Telemetry remit",
|
|
13257
|
+
credential: typeof campaignKey === "string" && campaignKey.trim() ? "the campaign key" : null,
|
|
13258
|
+
});
|
|
13259
|
+
wouldRemitEndpoint = `${base}${DEFAULT_RUNS_ENDPOINT}`;
|
|
13260
|
+
} catch (error) {
|
|
13261
|
+
wouldRemitRefusal = String(error?.message ?? error);
|
|
13262
|
+
}
|
|
13263
|
+
}
|
|
12528
13264
|
const remitStatus = shouldAttemptRemit
|
|
12529
13265
|
? await remitRunRecord(record, { proxyBase, consent, campaignKey })
|
|
12530
13266
|
: { attempted: false, ok: null, error: null, endpoint: null, result: null, http_status: null };
|
|
@@ -12543,7 +13279,11 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
|
|
|
12543
13279
|
record.remit_base_kind = remitStatus.attempted ? remitBaseKind : null;
|
|
12544
13280
|
}
|
|
12545
13281
|
|
|
12546
|
-
|
|
13282
|
+
// The record's one and only write, after the remit so the file that lands
|
|
13283
|
+
// carries this run's remit_* outcome rather than the placeholders it was
|
|
13284
|
+
// assembled with. Validated above, ahead of the send; nothing is written
|
|
13285
|
+
// under --dry-run or --no-write.
|
|
13286
|
+
const recordPath = write ? writeRunRecord(record, { baseDir }) : null;
|
|
12547
13287
|
|
|
12548
13288
|
const summary = {
|
|
12549
13289
|
ok: true,
|
|
@@ -12569,6 +13309,13 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
|
|
|
12569
13309
|
sent: remitStatus.attempted,
|
|
12570
13310
|
preserved: Boolean(carriedForward),
|
|
12571
13311
|
},
|
|
13312
|
+
// What a real run of this same command line — the one without --dry-run —
|
|
13313
|
+
// would have done: the record file it would write, and the endpoint it
|
|
13314
|
+
// would POST to (null when --no-remit, --no-write or consent would have
|
|
13315
|
+
// stopped the send anyway, and null with `would_remit_refused` set when the
|
|
13316
|
+
// transport's destination gate refuses the base before any socket opens).
|
|
13317
|
+
// Envelope only; never on the record.
|
|
13318
|
+
...(dryRun ? { dry_run: true, would_write: resolveRunRecordPath(runId, baseDir), would_remit: wouldRemitEndpoint, would_remit_refused: wouldRemitRefusal } : {}),
|
|
12572
13319
|
};
|
|
12573
13320
|
if (silent) return summary;
|
|
12574
13321
|
if (args.json) {
|
|
@@ -12589,10 +13336,33 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
|
|
|
12589
13336
|
console.log(`Remit: skipped (consent ${record.consent_state}${remitDisabled ? ", disabled for this run" : ""}).`);
|
|
12590
13337
|
}
|
|
12591
13338
|
if (write) console.log(`Wrote: ${recordPath}`);
|
|
12592
|
-
else
|
|
13339
|
+
else if (dryRun) {
|
|
13340
|
+
console.log("Dry run (--dry-run). Nothing written, nothing sent.");
|
|
13341
|
+
console.log(`Would write: ${summary.would_write}`);
|
|
13342
|
+
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)")}`);
|
|
13343
|
+
} else console.log("Dry run only (--no-write). No record written, no remit.");
|
|
12593
13344
|
return summary;
|
|
12594
13345
|
}
|
|
12595
13346
|
|
|
13347
|
+
/**
|
|
13348
|
+
* The Run Record's refusal, lifted out of the write. `writeRunRecord` validates
|
|
13349
|
+
* the record before it writes and refuses an invalid one; that refusal belongs
|
|
13350
|
+
* to the command rather than to the write, because it has to fire on the dry
|
|
13351
|
+
* path (which never writes) and ahead of the remit on the real one (which
|
|
13352
|
+
* writes only afterwards). Both modes therefore refuse the same record with the
|
|
13353
|
+
* same message and the same exit code, before any send: the check fires here,
|
|
13354
|
+
* never on the identical one inside run-record.mjs (which stays as the writer's
|
|
13355
|
+
* own last guard). The validator is that writer's — imported, not re-stated —
|
|
13356
|
+
* so the two cannot disagree about what a valid record is. Writes nothing.
|
|
13357
|
+
*/
|
|
13358
|
+
function assertRunRecordValid(record) {
|
|
13359
|
+
const validation = validateRunRecord(record);
|
|
13360
|
+
if (!validation.ok) {
|
|
13361
|
+
const detail = validation.errors.map((error) => `[${error.code}] ${error.message}`).join("; ");
|
|
13362
|
+
throw new Error(`Run Record failed validation; refusing to write: ${detail}`);
|
|
13363
|
+
}
|
|
13364
|
+
}
|
|
13365
|
+
|
|
12596
13366
|
// The record already written under `runId` for this target, or null when there
|
|
12597
13367
|
// is none, it cannot be parsed, or it is not a valid Run Record. Only a record
|
|
12598
13368
|
// `writeRunRecord` could have written is trusted as a prior — the same
|
|
@@ -12733,7 +13503,7 @@ function parseRunRecordSurfaces(value) {
|
|
|
12733
13503
|
const surfaces = parseCommaList(value);
|
|
12734
13504
|
const unknown = surfaces.filter((surface) => !RUN_RECORD_SURFACES.includes(surface));
|
|
12735
13505
|
if (unknown.length) {
|
|
12736
|
-
throw
|
|
13506
|
+
throw refused(`Unknown --surfaces value(s): ${unknown.join(", ")}. Use one of: ${RUN_RECORD_SURFACES.join(", ")}.`);
|
|
12737
13507
|
}
|
|
12738
13508
|
return surfaces;
|
|
12739
13509
|
}
|
|
@@ -12823,6 +13593,21 @@ function toolkitProvenance({ silent = false } = {}) {
|
|
|
12823
13593
|
// canonical endpoint, or against --proxy-base when given, so it reports what
|
|
12824
13594
|
// a remit to that endpoint would do. `off` takes no --proxy-base: an OFF
|
|
12825
13595
|
// choice is machine-wide and the record it writes carries no scope.
|
|
13596
|
+
// `assertSecureProxyBase` is SHARED with the remit rail, where it runs in the
|
|
13597
|
+
// middle of a handler: `remit()` reaches it after the Run Record has been built
|
|
13598
|
+
// and written, and `spec derive --write-map` after the derive produced one. A
|
|
13599
|
+
// throw from those positions is a handler failure — the most valuable lifecycle
|
|
13600
|
+
// entry there is — so the refusal tag cannot live inside the validator.
|
|
13601
|
+
//
|
|
13602
|
+
// The call sites below are the up-front ones: the base comes straight off argv
|
|
13603
|
+
// and is checked before any configuration read or write, before a credential is
|
|
13604
|
+
// attached, and before a request. The tag therefore goes HERE, where the
|
|
13605
|
+
// position is known — `refusing()` is the shared form of that contract. The
|
|
13606
|
+
// message and the exit code stay the validator's own; only the verdict the
|
|
13607
|
+
// lifecycle journal reads is added.
|
|
13608
|
+
const refuseInsecureProxyBase = (proxyBase, options) =>
|
|
13609
|
+
refusing(() => assertSecureProxyBase(proxyBase, options));
|
|
13610
|
+
|
|
12826
13611
|
async function telemetryCommand(args) {
|
|
12827
13612
|
const sub = args._[1] || "status";
|
|
12828
13613
|
const configPath = resolveConfigPath();
|
|
@@ -12831,17 +13616,17 @@ async function telemetryCommand(args) {
|
|
|
12831
13616
|
// empty variable) is not "no flag": treating it as absent would grant or
|
|
12832
13617
|
// check the canonical endpoint under a request that named something else.
|
|
12833
13618
|
if (Object.hasOwn(args, "proxy-base") && !requestedBase) {
|
|
12834
|
-
throw
|
|
13619
|
+
throw refused(`telemetry ${sub}: --proxy-base needs a URL (https, or a loopback host); nothing was written.`);
|
|
12835
13620
|
}
|
|
12836
13621
|
// Same transport rule as the remit rail: https, or a loopback host. A grant
|
|
12837
13622
|
// for a base a remit would refuse to send to is not a grant, and a status
|
|
12838
13623
|
// check against one would report on a remit that can never happen. Nothing
|
|
12839
13624
|
// is sent here, so the in-clear warning is left to the remit.
|
|
12840
|
-
const secureBase = () =>
|
|
13625
|
+
const secureBase = () => refuseInsecureProxyBase(requestedBase, { label: `telemetry ${sub}`, warn: () => {} }).base;
|
|
12841
13626
|
|
|
12842
13627
|
if (sub === "on" || sub === "off") {
|
|
12843
13628
|
if (sub === "off" && requestedBase) {
|
|
12844
|
-
throw
|
|
13629
|
+
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
13630
|
}
|
|
12846
13631
|
const proxyBase = requestedBase ? secureBase() : DEFAULT_PROXY_BASE;
|
|
12847
13632
|
const { configPath: written, config } = writeConsentConfig(sub, { configPath, proxyBase, source: "telemetry-command" });
|
|
@@ -12917,7 +13702,7 @@ async function telemetryCommand(args) {
|
|
|
12917
13702
|
|
|
12918
13703
|
if (sub === "list") return telemetryList(args);
|
|
12919
13704
|
|
|
12920
|
-
throw
|
|
13705
|
+
throw refused(`Unknown telemetry subcommand "${sub}". Use: status | on | off | list.`);
|
|
12921
13706
|
}
|
|
12922
13707
|
|
|
12923
13708
|
// `telemetry list` — the reader that never existed. Since the receiver
|
|
@@ -12932,14 +13717,16 @@ const TELEMETRY_LIST_TIMEOUT_MS = 15_000;
|
|
|
12932
13717
|
|
|
12933
13718
|
const TELEMETRY_LIST_MAX_BODY_BYTES = 4_000_000; // the receiver caps a listing at 500 summaries
|
|
12934
13719
|
|
|
12935
|
-
async function telemetryList(args, { fetchImpl = globalThis.fetch } = {}) {
|
|
12936
|
-
if (typeof fetchImpl !== "function") throw
|
|
13720
|
+
export async function telemetryList(args, { fetchImpl = globalThis.fetch } = {}) {
|
|
13721
|
+
if (typeof fetchImpl !== "function") throw refused("Global fetch is not available. Upgrade to Node 18+.");
|
|
12937
13722
|
// 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
|
-
|
|
13723
|
+
// loud warning that the credential is in clear. Anything else is refused
|
|
13724
|
+
// here, before a credential is attached to a request and before the packet
|
|
13725
|
+
// below is read — the same up-front position the gate holds under
|
|
13726
|
+
// `telemetry status|on`, so it is tagged the same way. The gate's own
|
|
13727
|
+
// normalized base is what the consent scope and the request URL below are
|
|
13728
|
+
// built from, so one string decides both.
|
|
13729
|
+
const { url: proxyUrl, base: proxyBase, loopback } = refuseInsecureProxyBase(
|
|
12943
13730
|
optionalString(args["proxy-base"]) || DEFAULT_PROXY_BASE,
|
|
12944
13731
|
{ label: "telemetry list", credential: "the listing credential (the ops admin key, or the packet's campaign key)" },
|
|
12945
13732
|
);
|
|
@@ -12951,9 +13738,11 @@ async function telemetryList(args, { fetchImpl = globalThis.fetch } = {}) {
|
|
|
12951
13738
|
const { key, rejected } = resolveCampaignsApiKeySource(packet, packetPath, process.env);
|
|
12952
13739
|
const rejection = describeCampaignKeyRejection(rejected);
|
|
12953
13740
|
// 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
|
-
|
|
13741
|
+
// the operator can fix it, and nothing is sent in the meantime. Every
|
|
13742
|
+
// refusal ahead of the request is tagged, so the lifecycle journal records
|
|
13743
|
+
// nothing for it — the same rule as a flag refused up front.
|
|
13744
|
+
if (rejection) throw refused(`telemetry list --packet: ${rejection}`);
|
|
13745
|
+
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
13746
|
headers["X-Campaign-Key"] = key;
|
|
12958
13747
|
scope = "tenant";
|
|
12959
13748
|
} else {
|
|
@@ -12963,11 +13752,12 @@ async function telemetryList(args, { fetchImpl = globalThis.fetch } = {}) {
|
|
|
12963
13752
|
// posture remit takes with default-on consent.
|
|
12964
13753
|
const canonical = normalizeConsentScope(proxyBase) === CANONICAL_REMIT_SCOPE;
|
|
12965
13754
|
if (!canonical && !loopback && args["trust-proxy-base"] !== true) {
|
|
12966
|
-
throw
|
|
13755
|
+
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
13756
|
}
|
|
12968
13757
|
const envName = optionalString(args["admin-key-env"]) || DEFAULT_ADMIN_KEY_ENV;
|
|
12969
13758
|
const adminKey = process.env[envName];
|
|
12970
|
-
if (!isNonEmptyString(adminKey)) throw
|
|
13759
|
+
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.`);
|
|
13760
|
+
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
13761
|
headers["X-Campaigns-Ops-Admin-Key"] = adminKey.trim();
|
|
12972
13762
|
scope = "admin";
|
|
12973
13763
|
}
|
|
@@ -13291,7 +14081,8 @@ export function resultTextLines(result, { headerLines = [] } = {}) {
|
|
|
13291
14081
|
// A waive command's second line names what it recorded; the third is the
|
|
13292
14082
|
// stage doctor now picks for the report the waiver was written to.
|
|
13293
14083
|
if (WAIVE_ACTIONS.has(result.action) && result.gate) {
|
|
13294
|
-
lines.push(
|
|
14084
|
+
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}` : ""}`);
|
|
14085
|
+
if (result.dry_run) lines.push(`Would write: ${result.would_write} (nothing was written)`);
|
|
13295
14086
|
if (result.next_stage) lines.push(`Next stage: ${result.next_stage}${result.next_stage_reason ? ` (${result.next_stage_reason})` : ""}`);
|
|
13296
14087
|
}
|
|
13297
14088
|
if (result.targets?.length) {
|
|
@@ -13386,13 +14177,13 @@ export async function specDeriveWithMapWriteback(args, { fetchImpl = undefined,
|
|
|
13386
14177
|
// by a stray value. `--proxy-base` names a URL or is refused here, before
|
|
13387
14178
|
// anything is read, as `telemetry` refuses a bare one.
|
|
13388
14179
|
if (Object.hasOwn(args, "write-map") && args["write-map"] !== true) {
|
|
13389
|
-
throw
|
|
14180
|
+
throw refused(`--write-map takes no value (got ${JSON.stringify(args["write-map"])}); write \`--write-map\` on its own, after the other flags.`);
|
|
13390
14181
|
}
|
|
13391
14182
|
if (Object.hasOwn(args, "proxy-base") && !optionalString(args["proxy-base"])) {
|
|
13392
|
-
throw
|
|
14183
|
+
throw refused("spec derive: --proxy-base needs a URL (https, or a loopback host); nothing was written.");
|
|
13393
14184
|
}
|
|
13394
14185
|
if (optionalString(args["proxy-base"]) && args["write-map"] !== true) {
|
|
13395
|
-
throw
|
|
14186
|
+
throw refused("spec derive: --proxy-base only applies with --write-map; nothing was written.");
|
|
13396
14187
|
}
|
|
13397
14188
|
const writeMap = args["write-map"] === true;
|
|
13398
14189
|
const localArgs = Object.fromEntries(Object.entries(args).filter(([key]) => !SPEC_DERIVE_MAP_FLAGS.includes(key)));
|