@bridge_gpt/mcp-server 0.2.66 → 0.2.67

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.md +252 -77
  2. package/build/agent-capabilities/probes.js +15 -5
  3. package/build/agent-launchers/claude-executor-adapter.js +43 -16
  4. package/build/agent-launchers/executor-adapter.js +7 -4
  5. package/build/agents.generated.js +1 -1
  6. package/build/bounded-wait.js +67 -10
  7. package/build/commands.generated.js +11 -11
  8. package/build/conductor/bridge-api-client.js +143 -1
  9. package/build/conductor/bring-up-facts.js +1 -1
  10. package/build/conductor/doctor.js +3 -3
  11. package/build/conductor/epic-implementer-cli.js +22 -6
  12. package/build/conductor/errors.js +4 -3
  13. package/build/conductor/guided-repair.js +6 -2
  14. package/build/conductor/hook-runtime-qualification.js +1 -1
  15. package/build/conductor/merge-execution-contract.js +27 -8
  16. package/build/conductor/merge-execution-eligibility.js +3 -2
  17. package/build/conductor/pr-ci-producer.js +1 -1
  18. package/build/conductor/readiness-cli.js +18 -2
  19. package/build/conductor/readiness-sections.js +41 -6
  20. package/build/conductor/readiness.js +181 -29
  21. package/build/conductor/recovery-cli.js +200 -8
  22. package/build/conductor/recovery-operations.js +69 -3
  23. package/build/conductor/repair-contract.js +54 -3
  24. package/build/conductor/repository-workflow-remedy.js +125 -0
  25. package/build/conductor/tools.js +2 -2
  26. package/build/conductor-bundle-artifacts.js +8 -0
  27. package/build/docs.generated.js +8 -3
  28. package/build/drive-epic-key.js +6 -5
  29. package/build/drive-epic.js +85 -54
  30. package/build/epic-coherence-input.js +2 -1
  31. package/build/epic-implementer-bin.js +22 -20
  32. package/build/epic-implementer-bundle-cli.js +2 -2
  33. package/build/executor/cli.js +3 -2
  34. package/build/executor/completion-contract.js +48 -5
  35. package/build/executor/completion-supervision.js +4 -0
  36. package/build/executor/env.js +19 -0
  37. package/build/executor/heartbeat.js +1 -1
  38. package/build/executor/http-client.js +20 -0
  39. package/build/executor/job-errors.js +25 -0
  40. package/build/executor/job-runner.js +225 -41
  41. package/build/executor/observation.js +6 -1
  42. package/build/executor/preflight.js +51 -14
  43. package/build/executor/required-work-ledger.js +6 -1
  44. package/build/executor/resume-pre-spawn.js +2 -0
  45. package/build/executor/runner.js +8 -2
  46. package/build/executor/service-unit.js +7 -1
  47. package/build/executor/worker-finalization.js +82 -29
  48. package/build/executor/worker-guard-hook.js +137 -33
  49. package/build/executor/worktree.js +82 -1
  50. package/build/index.js +403 -429
  51. package/build/install-bridge-conductor.js +8 -6
  52. package/build/install-bridge.js +3 -3
  53. package/build/phase-context-git-probe.js +68 -0
  54. package/build/phase-result-artifacts.js +173 -5
  55. package/build/pipelines.generated.js +18 -24
  56. package/build/plane/alembic-head.js +12 -3
  57. package/build/plane/base-worktree-guard.js +147 -0
  58. package/build/plane/cli.js +217 -17
  59. package/build/plane/code-snapshot.js +468 -0
  60. package/build/plane/defaults.js +48 -3
  61. package/build/plane/endpoint.js +41 -0
  62. package/build/plane/manifest.js +120 -1
  63. package/build/plane/member-roster.js +94 -19
  64. package/build/plane/preflight.js +30 -2
  65. package/build/plane/process-cwd.js +67 -0
  66. package/build/plane/restart.js +542 -0
  67. package/build/plane/shutdown.js +31 -0
  68. package/build/plane/status.js +143 -8
  69. package/build/plane/supervisor.js +586 -179
  70. package/build/plane/types.js +54 -2
  71. package/build/printed-remedy-commands.js +37 -0
  72. package/build/readiness-check.js +1 -1
  73. package/build/readme.generated.js +1 -1
  74. package/build/review-generation.js +22 -0
  75. package/build/setup-epic.js +44 -19
  76. package/build/sfcc/config.js +9 -0
  77. package/build/sfcc/credentials.js +100 -38
  78. package/build/sfcc/hostname.js +67 -0
  79. package/build/sfcc/permissions.js +17 -13
  80. package/build/sfcc/register.js +1 -1
  81. package/build/sfcc/tool-wrapper.js +39 -43
  82. package/build/sfcc/write-grants.js +17 -11
  83. package/build/sfcc/write-result.js +4 -3
  84. package/build/sfcc/writes-site-preference.js +20 -8
  85. package/build/start-tickets.js +179 -31
  86. package/build/tool-error-envelope.js +61 -0
  87. package/build/version.generated.js +3 -3
  88. package/build/visual-diff.js +22 -1
  89. package/build/worker-guard-hook-bin.js +3 -6
  90. package/build/worker-guard-hook-main.js +47 -0
  91. package/build/worktree-core.js +130 -4
  92. package/docs/EPIC-IMPLEMENTER-INSTALL.md +849 -0
  93. package/docs/EPIC-IMPLEMENTER.md +662 -0
  94. package/docs/install/mcp-tool-integrations.md +24 -15
  95. package/docs/install/sfcc-integration.md +12 -4
  96. package/package.json +1 -1
  97. package/pipelines/plan-epic.json +0 -5
  98. package/pipelines/review-ticket.json +1 -1
  99. package/docs/CONDUCTOR.md +0 -605
  100. package/pipelines/idea-to-pr.json +0 -49
@@ -193,6 +193,28 @@ export class ReviewGenerationRegistry {
193
193
  return null;
194
194
  return { baseline: entry.baseline, expectedLegCount: entry.expectedLegCount };
195
195
  }
196
+ /**
197
+ * Consecutive all-`404` wait windows recorded for the live submission
198
+ * (BAPI-1382), or `0` when there is none or no live entry.
199
+ */
200
+ notFoundWindowCount(repoName, ticketNumber) {
201
+ this.evictExpired();
202
+ return this.entries.get(baselineKey(repoName, ticketNumber))?.notFoundWindows ?? 0;
203
+ }
204
+ /** Count one more all-`404` wait window for the live submission. No-op without one. */
205
+ noteNotFoundWindow(repoName, ticketNumber) {
206
+ this.evictExpired();
207
+ const entry = this.entries.get(baselineKey(repoName, ticketNumber));
208
+ if (!entry)
209
+ return;
210
+ entry.notFoundWindows = (entry.notFoundWindows ?? 0) + 1;
211
+ }
212
+ /** A non-`404` observation resets the count. No-op without a live entry. */
213
+ resetNotFoundWindows(repoName, ticketNumber) {
214
+ const entry = this.entries.get(baselineKey(repoName, ticketNumber));
215
+ if (entry)
216
+ entry.notFoundWindows = 0;
217
+ }
196
218
  /**
197
219
  * Consume a baseline. Clearing means "durable persistence has happened",
198
220
  * not "a terminal envelope was received" (BAPI-1129) — the anchor is
@@ -1,10 +1,14 @@
1
1
  /**
2
- * `setup-epic` — one-command bootstrap for an Epic Conductor v2 run.
2
+ * `setup-epic` — one-command bootstrap for an Epic Implementer run.
3
3
  *
4
4
  * Creates the epic run, stores the plan DAG, and approves it. Before this
5
- * existed the only path was hand-rolled HTTP with a hand-computed plan hash,
6
- * which is why the shipped `store-and-approve-epic-plan` pipeline step told the
7
- * agent to "make a direct API call" — there was no tool to call.
5
+ * existed the only path was hand-rolled HTTP with a hand-computed plan hash —
6
+ * there was no tool to call.
7
+ *
8
+ * This is a separate run-creation workflow, not a `/plan-epic` pipeline step:
9
+ * `/plan-epic` authors the local sidecar and stops. An operator creates the child
10
+ * tickets, finalizes the plan, and then launches through the gated `drive-epic`
11
+ * entry point, which is what reaches this command.
8
12
  *
9
13
  * Ordering is load-bearing:
10
14
  *
@@ -85,8 +89,9 @@ import { formatEpicLaunchChannelIdentity, getPinnedEpicLaunchPolicy, readEpicLau
85
89
  // that module never imports from `setup-epic.ts`). Reused here rather than
86
90
  // reimplemented so the mapping stays single-defined.
87
91
  import { mapLegacyMergeExecutionModeToPolicyMode, resolveMergeExecutionMode, } from "./merge-pull-request.js";
88
- import { MERGE_EXECUTION_INELIGIBLE, MERGE_EXECUTION_MODE_GITHUB_ACTIONS, MERGE_WORKFLOW_REMEDY_COMMAND, } from "./conductor/merge-execution-contract.js";
92
+ import { MERGE_EXECUTION_INELIGIBLE, MERGE_EXECUTION_MODE_GITHUB_ACTIONS, MERGE_WORKFLOW_CODEOWNER_INSTRUCTION, MERGE_WORKFLOW_REMEDY_COMMAND, } from "./conductor/merge-execution-contract.js";
89
93
  import { classifyMergeExecutionEligibility } from "./conductor/merge-execution-eligibility.js";
94
+ import { repositoryWorkflowRemedy } from "./conductor/repository-workflow-remedy.js";
90
95
  import { resolveTrackedPaths } from "./git-reachability.js";
91
96
  import { validateBranchName } from "./base-ref.js";
92
97
  // Reused from the implement-epic client on purpose: adding a second wrapper under
@@ -100,6 +105,7 @@ import { validateBranchName } from "./base-ref.js";
100
105
  import { getConfigFieldBaseBranch, getIndexScopeLifecycle, getParseStatus, } from "./epic-shared/bridge-client.js";
101
106
  import { boundScopeLifecycleLabel, createExecFileRunCommand, ensureCommitResolvableLocally, performExactIndexScopeCut, pollIndexScopeLifecycle, readRemoteBranchHead, runGit, SCOPE_BOOTSTRAP_POLL_INTERVAL_MS, SCOPE_LIFECYCLE_LABELS, } from "./epic-shared/cut-protocol.js";
102
107
  import { hashPlan } from "./conductor/plan.js";
108
+ import { PLACEHOLDER_NODE_KEY_RE } from "./conductor-bundle-artifacts.js";
103
109
  import { BRING_UP_REFUSAL_MESSAGES, firstBringUpRefusal, } from "./conductor/bring-up-facts.js";
104
110
  import { MCP_PACKAGE_NAME } from "./mcp-identity.js";
105
111
  // BAPI-872: local plane-manifest binding, so `plane down` can later stop the
@@ -190,7 +196,7 @@ export function getSetupEpicUsage() {
190
196
  return [
191
197
  "Usage: mcp-server setup-epic --epic-key <KEY> --plan-file <path> [options]",
192
198
  "",
193
- "Bootstraps an Epic Conductor v2 run: creates the run, stores the plan DAG,",
199
+ "Bootstraps an Epic Implementer run: creates the run, stores the plan DAG,",
194
200
  "and approves it. Idempotent — re-running reuses an existing live run.",
195
201
  "",
196
202
  "Branch strategy (default): a plan with TWO OR MORE nodes runs on a dedicated",
@@ -210,11 +216,12 @@ export function getSetupEpicUsage() {
210
216
  "that selects no epic branch (--into-base, or a single-node plan) is unchanged",
211
217
  "and composes no posture at all.",
212
218
  "",
213
- "Unattended runs have TWO repository prerequisites, refused by name before any",
214
- "run is created: repository consent (ON by default; the repository owner can",
219
+ "Unattended runs have THREE repository prerequisites, refused by name before",
220
+ "any run is created: repository consent (ON by default; the repository owner can",
215
221
  "switch 'Allow unattended Epic Implementer runs' off and on in the Bridge admin",
216
- "panel's Setup page) and a verified notify webhook default. `drive-epic`",
217
- "readiness lists both with their current state.",
222
+ "panel's Setup page); an escalation channel, either a verified notify webhook or",
223
+ "the account's email address; and confirmed review and CI workflows on the",
224
+ "default branch. `drive-epic` readiness lists all three with their current state.",
218
225
  "",
219
226
  "Required:",
220
227
  " --epic-key <KEY> Jira epic key (e.g. BAPI-405)",
@@ -279,7 +286,7 @@ export function getSetupEpicUsage() {
279
286
  " 10799; remediate / ci_fix 3599; rebase / smoke 2699;",
280
287
  " merge 1799); the server rejects a higher value with a",
281
288
  " 422 naming the offending field. Full table:",
282
- " docs/claude/epic-implementer-v2-operator-runbook.md §4.",
289
+ " docs/epic-implementer/operator-runbook.md §4.",
283
290
  " --replace-policy Authorize replacing a LIVE run's stored policy with the",
284
291
  " policy file, as a complete replacement. Without it, a",
285
292
  " divergent policy on a reused run is refused with a",
@@ -297,8 +304,8 @@ export function getSetupEpicUsage() {
297
304
  " never a substitute for authoritative validation.",
298
305
  "",
299
306
  "After setup, the server-side reconciler picks the run up within ~30s.",
300
- "To execute claimed jobs on this machine, run:",
301
- ` npx -y ${MCP_PACKAGE_NAME} executor --repo <name>`,
307
+ "To execute this run's claimed jobs on this machine, run:",
308
+ ` npx -y ${MCP_PACKAGE_NAME} executor --repo <name> --epic-run-id <run-id>`,
302
309
  ].join("\n");
303
310
  }
304
311
  function takeValue(argv, i, flag) {
@@ -988,6 +995,20 @@ export function validateEpicPlanSidecar(parsed) {
988
995
  `to get that scheduling protection (use [] when no files are predicted).`);
989
996
  }
990
997
  }
998
+ // BAPI-1366: refuse unfinalized `TBD-N` node keys before any graph, access, or
999
+ // network work. `/plan-epic` authors placeholders; finalization replaces them.
1000
+ // This runs here, not in the server lane, so an updated client refuses even
1001
+ // against an older server and in `--dry-run --local-only`.
1002
+ const placeholders = [...keys].filter((key) => PLACEHOLDER_NODE_KEY_RE.test(key)).sort();
1003
+ if (placeholders.length > 0) {
1004
+ return {
1005
+ ok: false,
1006
+ error: `Plan node keys ${placeholders.join(", ")} are placeholders, not tickets. ` +
1007
+ `Create the child tickets and run \`emit-epic-implementer-bundle finalize\` ` +
1008
+ `to replace every placeholder, then run this again (this invocation created ` +
1009
+ `no run, cut no branch, and incurred no charge).`,
1010
+ };
1011
+ }
991
1012
  // Build one adjacency in a consistent predecessor -> successor direction, the
992
1013
  // same way the server does. `depends_on` points at the predecessor; an edge's
993
1014
  // `from` IS the predecessor.
@@ -1293,12 +1314,13 @@ export function evaluateSetupEpicUnattendedPrerequisites(readiness, requestedPol
1293
1314
  };
1294
1315
  }
1295
1316
  if (!unattended.repository_readiness_confirmed) {
1317
+ // BAPI-1359: the shared formatter names the specific missing item; the reason
1318
+ // code and the trailing sentence are unchanged.
1296
1319
  return {
1297
1320
  reason: "repository_readiness_unconfirmed",
1298
1321
  message: "the unattended default authorizes automatic merge, which requires the review and " +
1299
- "conductor-CI workflows to be CONFIRMED on the default branch, and they are not. " +
1300
- "Run `install-bridge conductor` to install them, or pass --attended to create an " +
1301
- "attended run. No run was created.",
1322
+ "epic-implementer-ci workflows to be CONFIRMED on the default branch, and they are not. " +
1323
+ `${repositoryWorkflowRemedy(readiness)} No run was created.`,
1302
1324
  };
1303
1325
  }
1304
1326
  return null;
@@ -1371,8 +1393,10 @@ export function evaluateSetupEpicMergeExecutionPrerequisites(readiness, requeste
1371
1393
  reason: MERGE_EXECUTION_INELIGIBLE,
1372
1394
  message: "this run declares merge_execution_mode: github_actions, and the Bridge API " +
1373
1395
  "conductor readiness report could not be read, so the repository's merge " +
1374
- "capability could not be confirmed. Retry once the server is reachable, or " +
1375
- `resolve it with \`${MERGE_WORKFLOW_REMEDY_COMMAND}\`. ${NO_RUN_WAS_CREATED}`,
1396
+ "capability could not be confirmed. Retry once the server is reachable. If " +
1397
+ "readiness then reports the merge workflow missing or out of date, resolve it " +
1398
+ `with \`${MERGE_WORKFLOW_REMEDY_COMMAND}\`. ${MERGE_WORKFLOW_CODEOWNER_INSTRUCTION} ` +
1399
+ NO_RUN_WAS_CREATED,
1376
1400
  };
1377
1401
  }
1378
1402
  const verdict = classifyMergeExecutionEligibility(readiness);
@@ -1385,7 +1409,8 @@ export function evaluateSetupEpicMergeExecutionPrerequisites(readiness, requeste
1385
1409
  reason: MERGE_EXECUTION_INELIGIBLE,
1386
1410
  message: "this run declares merge_execution_mode: github_actions, and this repository's " +
1387
1411
  `merge capability ${category}: ${verdict.detail}. Resolve it with ` +
1388
- `\`${MERGE_WORKFLOW_REMEDY_COMMAND}\`, then re-run. ${NO_RUN_WAS_CREATED}`,
1412
+ `\`${MERGE_WORKFLOW_REMEDY_COMMAND}\`, then re-run. ` +
1413
+ `${MERGE_WORKFLOW_CODEOWNER_INSTRUCTION} ${NO_RUN_WAS_CREATED}`,
1389
1414
  };
1390
1415
  }
1391
1416
  /**
@@ -9,6 +9,15 @@
9
9
  export const SFCC_VERSIONS = ["sfra", "pwakit", "sitegenesis", "storefrontnext", "hybrid"];
10
10
  /** Pinned OCAPI Data API version used for all requests (configurable at call-time). */
11
11
  export const DEFAULT_OCAPI_VERSION = "v25_6";
12
+ /**
13
+ * The `_v` value of the OCAPI Settings JSON an operator pastes in Business
14
+ * Manager. This is the settings-STRUCTURE version, not the OCAPI API version
15
+ * above; Business Manager rejects an API version here.
16
+ *
17
+ * UNVERIFIED: "23.2" is provisional until owner check OR-1 saves the emitted
18
+ * grant in Business Manager. If OR-1 shows a different value, change it here.
19
+ */
20
+ export const OCAPI_SETTINGS_STRUCTURE_VERSION = "23.2";
12
21
  /** Account Manager OAuth2 token endpoint host. */
13
22
  export const AM_HOST = "account.demandware.com";
14
23
  /** AM OAuth2 token endpoint URL. */
@@ -1,12 +1,14 @@
1
1
  /**
2
2
  * SFCC credential resolution — dw.json only (D-2: no Bridge credential store).
3
3
  *
4
- * Precedence: explicit tool arg → environment variable → dw.json.
4
+ * Precedence: explicit tool arg (matched against dw.json, then environment
5
+ * variables) → environment variable → dw.json.
5
6
  * Multi-sandbox detection fails loudly with actionable guidance; no silent
6
7
  * first-pick. Secret values (client_secret, tokens) are never included in
7
8
  * any returned or thrown string — only source and target are reported.
8
9
  */
9
10
  import { ensureGitInfoExcluded } from "../git-ignore-utils.js";
11
+ import { canonicalizeSfccHostname, formatSafeHost } from "./hostname.js";
10
12
  import { readFile, writeFile, mkdir } from "fs/promises";
11
13
  import path from "path";
12
14
  // ---------------------------------------------------------------------------
@@ -20,13 +22,66 @@ const DW_JSON = "dw.json";
20
22
  // ---------------------------------------------------------------------------
21
23
  // Helpers
22
24
  // ---------------------------------------------------------------------------
23
- /** Redact-safe summary of a resolved hostname (no secrets). */
25
+ /** Redact-safe full hostname, bounded; a non-hostname becomes a fixed label. */
24
26
  function safeHostLabel(hostname) {
25
- return hostname.split(".")[0] ?? hostname;
27
+ return formatSafeHost(hostname);
26
28
  }
27
- // ---------------------------------------------------------------------------
28
- // Main resolver
29
- // ---------------------------------------------------------------------------
29
+ /** A dw.json config's credential fields, or null when any is missing. */
30
+ function readConfigCredentials(cfg) {
31
+ const hostname = String(cfg.hostname ?? cfg.host ?? "");
32
+ const clientId = String(cfg["client-id"] ?? cfg.clientId ?? cfg.client_id ?? "");
33
+ const clientSecret = String(cfg["client-secret"] ?? cfg.clientSecret ?? cfg.client_secret ?? "");
34
+ if (!hostname || !clientId || !clientSecret)
35
+ return null;
36
+ return { hostname, clientId, clientSecret };
37
+ }
38
+ /** Every config in a parsed dw.json: the `configs` array, plus a top-level config. */
39
+ function allConfigs(dwJson) {
40
+ const configs = Array.isArray(dwJson.configs) ? dwJson.configs : [];
41
+ const list = configs.filter((c) => typeof c === "object" && c !== null);
42
+ if (dwJson.hostname !== undefined || dwJson.host !== undefined)
43
+ list.push(dwJson);
44
+ return list;
45
+ }
46
+ /**
47
+ * Read dw.json once, keeping the git-exclude side effect.
48
+ *
49
+ * "absent" means the file genuinely is not there (or could not be read at all);
50
+ * "invalid" means it is present but is not a JSON object. The gate treats only
51
+ * "absent" as "this repository is not set up for SFCC".
52
+ */
53
+ async function loadDwJson(deps) {
54
+ const cwd = deps.cwd ?? process.cwd();
55
+ const rf = deps.readFile ?? ((p) => readFile(p, "utf-8"));
56
+ const wf = deps.writeFile ?? ((p, data) => writeFile(p, data, "utf-8"));
57
+ const mk = deps.mkdir ?? ((p, opts) => mkdir(p, opts));
58
+ // Ensure dw.json is excluded from git tracking.
59
+ try {
60
+ await ensureGitInfoExcluded(cwd, DW_JSON, { readFile: rf, writeFile: wf, mkdir: mk });
61
+ }
62
+ catch {
63
+ // Non-fatal — continue even if we can't update .git/info/exclude.
64
+ }
65
+ let raw;
66
+ try {
67
+ raw = await rf(path.join(cwd, DW_JSON));
68
+ }
69
+ catch {
70
+ return { state: "absent" };
71
+ }
72
+ try {
73
+ const parsed = JSON.parse(raw);
74
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
75
+ return { state: "invalid" };
76
+ }
77
+ return { state: "ok", dwJson: parsed };
78
+ }
79
+ catch {
80
+ return { state: "invalid" };
81
+ }
82
+ }
83
+ const DW_JSON_ABSENT_MESSAGE = "Could not read dw.json. Create a dw.json file in your project root " +
84
+ "with your SFCC sandbox credentials (hostname, client-id, client-secret).";
30
85
  /**
31
86
  * Resolve SFCC credentials with strict precedence and redacted error messages.
32
87
  *
@@ -37,14 +92,34 @@ function safeHostLabel(hostname) {
37
92
  export async function resolveSfccCredentials(explicitHostname, env = process.env, deps = {}) {
38
93
  // --- Tier 1: explicit tool argument ---
39
94
  if (explicitHostname) {
95
+ // A dw.json config for this exact host wins; environment credentials are
96
+ // the fallback when the file is absent, unusable, or has no usable match.
97
+ const explicitCanonical = canonicalizeSfccHostname(explicitHostname);
98
+ const loaded = await loadDwJson(deps);
99
+ if (loaded.state === "ok" && explicitCanonical !== null) {
100
+ for (const cfg of allConfigs(loaded.dwJson)) {
101
+ const cfgHost = canonicalizeSfccHostname(String(cfg.hostname ?? cfg.host ?? ""));
102
+ if (cfgHost !== explicitCanonical)
103
+ continue;
104
+ const found = readConfigCredentials(cfg);
105
+ if (found) {
106
+ return {
107
+ ok: true,
108
+ credentials: { ...found, source: "dw.json (explicit instance match)" },
109
+ };
110
+ }
111
+ }
112
+ }
40
113
  const clientId = env[ENV_CLIENT_ID];
41
114
  const clientSecret = env[ENV_CLIENT_SECRET];
42
115
  if (!clientId || !clientSecret) {
43
116
  return {
44
117
  ok: false,
45
- error: `Explicit instance '${safeHostLabel(explicitHostname)}' provided but ` +
46
- `${ENV_CLIENT_ID} and/or ${ENV_CLIENT_SECRET} are not set in environment. ` +
47
- `Set them and retry.`,
118
+ kind: "explicit-instance-unresolved",
119
+ error: `Explicit instance '${safeHostLabel(explicitHostname)}' could not be resolved: ` +
120
+ `no complete dw.json config matches it, and ${ENV_CLIENT_ID} and/or ` +
121
+ `${ENV_CLIENT_SECRET} are not set in environment. Add a matching config ` +
122
+ `to dw.json or set them, then retry.`,
48
123
  };
49
124
  }
50
125
  return {
@@ -73,30 +148,19 @@ export async function resolveSfccCredentials(explicitHostname, env = process.env
73
148
  };
74
149
  }
75
150
  // --- Tier 3: dw.json ---
76
- const cwd = deps.cwd ?? process.cwd();
77
- const rf = deps.readFile ?? ((p) => readFile(p, "utf-8"));
78
- const wf = deps.writeFile ?? ((p, data) => writeFile(p, data, "utf-8"));
79
- const mk = deps.mkdir ?? ((p, opts) => mkdir(p, opts));
80
- // Ensure dw.json is excluded from git tracking.
81
- try {
82
- await ensureGitInfoExcluded(cwd, DW_JSON, { readFile: rf, writeFile: wf, mkdir: mk });
83
- }
84
- catch {
85
- // Non-fatal — continue even if we can't update .git/info/exclude.
151
+ const loaded = await loadDwJson(deps);
152
+ if (loaded.state === "absent") {
153
+ return { ok: false, kind: "no-dw-json", error: DW_JSON_ABSENT_MESSAGE };
86
154
  }
87
- const dwJsonPath = path.join(cwd, DW_JSON);
88
- let dwJson;
89
- try {
90
- const raw = await rf(dwJsonPath);
91
- dwJson = JSON.parse(raw);
92
- }
93
- catch {
155
+ if (loaded.state === "invalid") {
94
156
  return {
95
157
  ok: false,
96
- error: "Could not read dw.json. Create a dw.json file in your project root " +
97
- "with your SFCC sandbox credentials (hostname, client-id, client-secret).",
158
+ kind: "dw-json-invalid",
159
+ error: "dw.json is present but is not a valid JSON object. Fix its syntax; it needs " +
160
+ "hostname, client-id, and client-secret.",
98
161
  };
99
162
  }
163
+ const dwJson = loaded.dwJson;
100
164
  // Detect multiple sandbox configurations.
101
165
  // dw.json may carry a top-level 'configs' array (multi-sandbox format) or just a single config.
102
166
  const configs = Array.isArray(dwJson.configs)
@@ -104,22 +168,22 @@ export async function resolveSfccCredentials(explicitHostname, env = process.env
104
168
  : null;
105
169
  if (configs && configs.length > 1) {
106
170
  const instances = configs
107
- .map((c) => safeHostLabel(String(c.hostname ?? c.host ?? "unknown")))
171
+ .map((c) => safeHostLabel(String(c.hostname ?? c.host ?? "")))
108
172
  .join(", ");
109
173
  return {
110
174
  ok: false,
175
+ kind: "multi-sandbox",
111
176
  error: `dw.json contains multiple sandboxes (${instances}). ` +
112
- `Pass an explicit 'instance' argument to select one: ${instances}.`,
177
+ `Pass one of these as 'instance' to select it: ${instances}.`,
113
178
  };
114
179
  }
115
180
  // Extract single-config credentials — support both top-level and configs[0] shapes.
116
181
  const cfg = configs && configs.length === 1 ? configs[0] : dwJson;
117
- const hostname = String(cfg.hostname ?? cfg.host ?? "");
118
- const clientId = String(cfg["client-id"] ?? cfg.clientId ?? cfg.client_id ?? "");
119
- const clientSecret = String(cfg["client-secret"] ?? cfg.clientSecret ?? cfg.client_secret ?? "");
120
- if (!hostname || !clientId || !clientSecret) {
182
+ const found = readConfigCredentials(cfg);
183
+ if (!found) {
121
184
  return {
122
185
  ok: false,
186
+ kind: "dw-json-invalid",
123
187
  error: "dw.json is present but missing required fields (hostname, client-id, client-secret). " +
124
188
  "Ensure all three fields are set.",
125
189
  };
@@ -127,10 +191,8 @@ export async function resolveSfccCredentials(explicitHostname, env = process.env
127
191
  return {
128
192
  ok: true,
129
193
  credentials: {
130
- hostname,
131
- clientId,
132
- clientSecret,
133
- source: `dw.json (instance: ${safeHostLabel(hostname)})`,
194
+ ...found,
195
+ source: `dw.json (instance: ${safeHostLabel(found.hostname)})`,
134
196
  },
135
197
  };
136
198
  }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Shared SFCC hostname helpers.
3
+ *
4
+ * `canonicalizeSfccHostname` lives here so the target boundary
5
+ * (`tool-wrapper.ts`), the credential resolver, and `check_permissions` all
6
+ * canonicalize and display hosts the same way. `tool-wrapper.ts` re-exports it
7
+ * so its public import surface is unchanged.
8
+ */
9
+ /** Longest hostname echoed in a diagnostic. */
10
+ export const MAX_DIAGNOSTIC_HOST_LENGTH = 120;
11
+ /** Shown in place of any value that is not a bare hostname. */
12
+ export const INVALID_HOST_LABEL = "<invalid host>";
13
+ /** One DNS label: 1–63 chars, alphanumeric, internal hyphens only. */
14
+ const DNS_LABEL = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
15
+ /**
16
+ * Reduce a raw hostname to a canonical comparable form, or `null` if it is not
17
+ * a bare hostname at all.
18
+ *
19
+ * Only DNS-equivalent normalization is applied — lowercasing and stripping a
20
+ * single trailing root dot. Everything else is a rejection rather than a
21
+ * repair: a value carrying a scheme, port, path, query, credentials, or
22
+ * whitespace is not a hostname, and "cleaning it up" would mean guessing which
23
+ * part the operator meant. Guessing is how a boundary gets bypassed, so this
24
+ * returns `null` and the caller fails closed.
25
+ */
26
+ export function canonicalizeSfccHostname(raw) {
27
+ if (typeof raw !== "string")
28
+ return null;
29
+ let value = raw.trim().toLowerCase();
30
+ if (value.endsWith("."))
31
+ value = value.slice(0, -1);
32
+ if (value === "" || value.length > 253)
33
+ return null;
34
+ // Anything that is not strictly `label(.label)*` — scheme, port, path, query,
35
+ // userinfo, whitespace, IPv6 brackets, wildcards.
36
+ if (!/^[a-z0-9.-]+$/.test(value))
37
+ return null;
38
+ if (value.includes(".."))
39
+ return null;
40
+ const labels = value.split(".");
41
+ if (labels.length < 2)
42
+ return null;
43
+ if (!labels.every((label) => DNS_LABEL.test(label)))
44
+ return null;
45
+ // A bare IPv4 address is not a sandbox hostname and must not be canonicalized
46
+ // into one; the suffix check in the boundary would reject it anyway, but
47
+ // rejecting here keeps "is this a hostname?" and "is this host approved?"
48
+ // separate concerns.
49
+ if (/^\d+(\.\d+)*$/.test(value))
50
+ return null;
51
+ return value;
52
+ }
53
+ /**
54
+ * Render a host for a diagnostic message.
55
+ *
56
+ * The value is canonicalized first. A value that is not a bare hostname is
57
+ * replaced by a fixed label, so arbitrary input (which may carry a secret) is
58
+ * never echoed. A valid hostname is shown in full, bounded to 120 characters.
59
+ */
60
+ export function formatSafeHost(raw) {
61
+ const canonical = canonicalizeSfccHostname(raw);
62
+ if (canonical === null)
63
+ return INVALID_HOST_LABEL;
64
+ return canonical.length > MAX_DIAGNOSTIC_HOST_LENGTH
65
+ ? `${canonical.slice(0, MAX_DIAGNOSTIC_HOST_LENGTH)}…`
66
+ : canonical;
67
+ }
@@ -8,14 +8,15 @@
8
8
  */
9
9
  import { ocapiGet } from "./client.js";
10
10
  import { normalizeOcapiPage } from "./ocapi-shape.js";
11
- import { DEFAULT_OCAPI_VERSION } from "./config.js";
11
+ import { DEFAULT_OCAPI_VERSION, OCAPI_SETTINGS_STRUCTURE_VERSION } from "./config.js";
12
+ import { formatSafeHost } from "./hostname.js";
12
13
  import { formatOcapiWriteGrantJson } from "./write-grants.js";
13
14
  // ---------------------------------------------------------------------------
14
15
  // OCAPI Settings JSON template
15
16
  // Operators paste this in: Administration > Site Development > Open Commerce API Settings → Data API tab.
16
17
  // ---------------------------------------------------------------------------
17
- const OCAPI_SETTINGS_READ_SEARCH = (ocapiVersion) => JSON.stringify({
18
- _v: ocapiVersion,
18
+ const OCAPI_SETTINGS_READ_SEARCH = () => JSON.stringify({
19
+ _v: OCAPI_SETTINGS_STRUCTURE_VERSION,
19
20
  clients: [
20
21
  {
21
22
  client_id: "<YOUR_CLIENT_ID>",
@@ -52,8 +53,9 @@ const OCAPI_SETTINGS_READ_SEARCH = (ocapiVersion) => JSON.stringify({
52
53
  // JSON printed here is byte-identical to what future write handlers echo on a
53
54
  // 403. This now covers all required write resource families:
54
55
  // /system_object_definitions, /system_object_definitions/**,
55
- // /custom_object_definitions/**, and /site_preferences/**.
56
- const OCAPI_SETTINGS_WRITE_IMPORT = (ocapiVersion) => formatOcapiWriteGrantJson(ocapiVersion);
56
+ // /custom_object_definitions/**, and the site-scoped /sites/*/site_preferences/**
57
+ // (the org-level /site_preferences/** above stays read/search only).
58
+ const OCAPI_SETTINGS_WRITE_IMPORT = () => formatOcapiWriteGrantJson();
57
59
  // ---------------------------------------------------------------------------
58
60
  // Tool implementation
59
61
  // ---------------------------------------------------------------------------
@@ -75,7 +77,7 @@ export async function checkPermissionsTool(credentials, ocapiVersion = DEFAULT_O
75
77
  {
76
78
  type: "text",
77
79
  text: `OCAPI probe failed: ${msg}\n\n` +
78
- ocapiSettingsInstructions(ocapiVersion),
80
+ ocapiSettingsInstructions(),
79
81
  },
80
82
  ],
81
83
  };
@@ -86,8 +88,10 @@ export async function checkPermissionsTool(credentials, ocapiVersion = DEFAULT_O
86
88
  content: [
87
89
  {
88
90
  type: "text",
89
- text: `✓ OCAPI access confirmed.\n` +
90
- ` Instance: ${credentials.hostname.split(".")[0]}\n` +
91
+ text: `✓ OCAPI read access confirmed (GET /system_object_definitions). ` +
92
+ `Write access is not probed; the sfcc-write tools need the mutation grant — ` +
93
+ `run check_permissions after a 403 or see SFCC-SETUP-CHECKLIST.md.\n` +
94
+ ` Instance: ${formatSafeHost(credentials.hostname)}\n` +
91
95
  ` OCAPI version: ${ocapiVersion}\n` +
92
96
  ` GET /system_object_definitions: ${formatProbeCount(normalized)}`,
93
97
  },
@@ -99,8 +103,8 @@ export async function checkPermissionsTool(credentials, ocapiVersion = DEFAULT_O
99
103
  content: [
100
104
  {
101
105
  type: "text",
102
- text: `HTTP ${result.status}: OCAPI access denied for instance ${credentials.hostname.split(".")[0]}.\n\n` +
103
- ocapiSettingsInstructions(ocapiVersion),
106
+ text: `HTTP ${result.status}: OCAPI access denied for instance ${formatSafeHost(credentials.hostname)}.\n\n` +
107
+ ocapiSettingsInstructions(),
104
108
  },
105
109
  ],
106
110
  };
@@ -157,13 +161,13 @@ function formatProbeCount(normalized) {
157
161
  * A label that understates a grant is worse than no label, because the operator
158
162
  * stops reading the JSON once the heading has told them it is safe.
159
163
  */
160
- function ocapiSettingsInstructions(ocapiVersion) {
164
+ function ocapiSettingsInstructions() {
161
165
  return (`To grant OCAPI access, paste the JSON below in Business Manager:\n` +
162
166
  ` Administration > Site Development > Open Commerce API Settings → Data API tab\n\n` +
163
167
  `--- READ/SEARCH TOOL GRANTS (required for the sfcc read tools) ---\n` +
164
- `${OCAPI_SETTINGS_READ_SEARCH(ocapiVersion)}\n\n` +
168
+ `${OCAPI_SETTINGS_READ_SEARCH()}\n\n` +
165
169
  `--- MUTATION GRANTS (required for the nine sfcc-write tools) ---\n` +
166
- `${OCAPI_SETTINGS_WRITE_IMPORT(ocapiVersion)}\n\n` +
170
+ `${OCAPI_SETTINGS_WRITE_IMPORT()}\n\n` +
167
171
  `Replace <YOUR_CLIENT_ID> with the client_id from your dw.json.`);
168
172
  }
169
173
  /**
@@ -89,7 +89,7 @@ export function registerSfccTools(registerTool, deps) {
89
89
  const gatedCheckPermissions = withSfccGate(gateDeps, async (_args, credentials) => checkPermissionsTool(credentials));
90
90
  registerSfccTool("check_permissions", {
91
91
  description: "Probe SFCC OCAPI access via GET /system_object_definitions. " +
92
- "On 200: reports OK and the detected OCAPI version. " +
92
+ "On 200: reports read access OK and the detected OCAPI version. " +
93
93
  "On 401/403: prints the exact OCAPI Settings JSON to paste in Business Manager " +
94
94
  "(read/search and mutation grants).",
95
95
  inputSchema: z.object({