@hone-ai/cli 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/bin/hone.js +2 -0
  2. package/hone-cli.js +4006 -0
  3. package/lib/README.md +119 -0
  4. package/lib/adversarial-negative-lint.js +149 -0
  5. package/lib/audit.js +156 -0
  6. package/lib/auto-detect.js +213 -0
  7. package/lib/autofix-guardrails.js +124 -0
  8. package/lib/branch-protection.js +256 -0
  9. package/lib/ci-classifier.js +150 -0
  10. package/lib/ci-failures.js +173 -0
  11. package/lib/claude-md-tokens.js +71 -0
  12. package/lib/compliance-check.js +62 -0
  13. package/lib/config-augment.js +133 -0
  14. package/lib/config-update.js +70 -0
  15. package/lib/dependency-audit.js +108 -0
  16. package/lib/derive-domain.js +185 -0
  17. package/lib/doc-registry.js +63 -0
  18. package/lib/doctor-admin-merge.js +185 -0
  19. package/lib/doctor-bind-default.js +118 -0
  20. package/lib/doctor-docs.js +205 -0
  21. package/lib/doctor-placeholders.js +144 -0
  22. package/lib/doctor-skill-staleness.js +122 -0
  23. package/lib/domain-skill-template.md +114 -0
  24. package/lib/editor-detect.js +169 -0
  25. package/lib/fast-track-ratify.js +133 -0
  26. package/lib/git-helpers.js +109 -0
  27. package/lib/hook-templates/pre-commit.sh +54 -0
  28. package/lib/hook-templates/pre-push.sh +72 -0
  29. package/lib/install-hooks.js +205 -0
  30. package/lib/knowledge-graph.js +188 -0
  31. package/lib/learnings-audit.js +254 -0
  32. package/lib/learnings-parse.js +331 -0
  33. package/lib/learnings-sync.js +75 -0
  34. package/lib/mcp-detect.js +154 -0
  35. package/lib/metrics-collect.js +214 -0
  36. package/lib/overlay-merge.js +267 -0
  37. package/lib/performance-analyzer.js +142 -0
  38. package/lib/pipeline-config.js +83 -0
  39. package/lib/pipeline-status.js +207 -0
  40. package/lib/pipeline-validate.js +322 -0
  41. package/lib/platform-detect.js +86 -0
  42. package/lib/platform-discover.js +334 -0
  43. package/lib/publish-learning.js +160 -0
  44. package/lib/python-install.js +84 -0
  45. package/lib/refresh-check.js +67 -0
  46. package/lib/refresh-knowledge.js +360 -0
  47. package/lib/rule-resolver.js +146 -0
  48. package/lib/security-scanner.js +168 -0
  49. package/lib/setup-grounding.js +138 -0
  50. package/lib/skill-assertions.js +276 -0
  51. package/lib/skill-audit-render.js +158 -0
  52. package/lib/skill-audit.js +391 -0
  53. package/lib/stack-detect.js +170 -0
  54. package/lib/stack-paths.js +285 -0
  55. package/lib/story-classifier-extract.js +203 -0
  56. package/lib/story-classifier.js +282 -0
  57. package/lib/sync-overwrite.js +47 -0
  58. package/lib/synthetic-pipeline.js +299 -0
  59. package/lib/validate-metadata.js +175 -0
  60. package/package.json +41 -0
@@ -0,0 +1,331 @@
1
+ 'use strict';
2
+ /**
3
+ * learnings-parse.js — H-035 schema-aware parser + write-back for the
4
+ * 3 active learnings YAML schemas. Pure helpers: callers do all I/O.
5
+ *
6
+ * Schemas (verified on hone-server + OptionsFlow develop, 2026-04-28):
7
+ * A) top-level list of items (legacy, OptionsFlow E13-A → E26-A)
8
+ * B) dict with `enterprise_candidates: [...]` (OptionsFlow E25-A onward)
9
+ * C) dict with `learnings: [...]` (hone-server H-001 onward)
10
+ *
11
+ * Without this module, `hone promote` only sees Schema A — Schemas B and C
12
+ * are silently dropped (no error, no warning), leading to invisible promotion
13
+ * data loss. Confirmed today: hone-server's own H-001/H-002a/H-003 learnings
14
+ * are 100% invisible to `hone promote` on develop@a549651.
15
+ *
16
+ * H-035 is the COMPATIBILITY SHIM that buys time for E29-H schema v2 to ship
17
+ * cleanly without breaking any production adopter. See
18
+ * docs/sdlc/PIPELINE_BACKLOG.md Phase 5 for the upstream lift plan.
19
+ *
20
+ * Closes #59 (H-035). Pairs with Phase 1.{2,3,4} of the backlog.
21
+ *
22
+ * Exports:
23
+ * - SCHEMA_A, SCHEMA_B, SCHEMA_C, SCHEMA_UNKNOWN : string constants
24
+ * - detectSchema(parsed) → 'A' | 'B' | 'C' | 'unknown'
25
+ * - parseLearningsFile(content, opts?) → { schema, eligible, warning?, parseError? }
26
+ * - writeBackPromoted(content, schema, idsPromoted) → newContent
27
+ *
28
+ * - parseSchemaA / parseSchemaB / parseSchemaC (exported for unit tests)
29
+ * - writeBackA / writeBackB / writeBackC (exported for unit tests)
30
+ */
31
+ const yaml = require('js-yaml');
32
+
33
+ const SCHEMA_A = 'A';
34
+ const SCHEMA_B = 'B';
35
+ const SCHEMA_C = 'C';
36
+ const SCHEMA_UNKNOWN = 'unknown';
37
+
38
+ // LC-001 / #58 G2 partial: optional categorization. Per architect plan for
39
+ // #58: schema extension only — auto-classifier deferred until OptionsFlow
40
+ // E29-H worked examples land. All 3 categories are advisory; tooling
41
+ // (audit-skills, audit-learnings) reads them when present.
42
+ const LEARNING_CATEGORIES = Object.freeze({
43
+ APPLIED_EXISTING_RULE: 'applied-existing-rule', // compliance evidence; reinforces an existing skill
44
+ DISCOVERED_NEW_PATTERN: 'discovered-new-pattern', // promotion candidate; novel insight
45
+ EXCEPTION_WITH_RATIONALE: 'exception-with-rationale', // documented deviation from a rule
46
+ });
47
+
48
+ const VALID_CATEGORY_VALUES = Object.values(LEARNING_CATEGORIES);
49
+
50
+ /**
51
+ * LC-001: validate optional category field. Returns the value if recognized,
52
+ * null if absent, throws if malformed.
53
+ */
54
+ function validateCategory(value) {
55
+ if (value === null || value === undefined || value === '') return null;
56
+ if (typeof value !== 'string') {
57
+ throw new Error(`learning.category must be a string, got ${typeof value}`);
58
+ }
59
+ if (!VALID_CATEGORY_VALUES.includes(value)) {
60
+ throw new Error(`learning.category must be one of [${VALID_CATEGORY_VALUES.join(', ')}], got "${value}"`);
61
+ }
62
+ return value;
63
+ }
64
+
65
+ // ─────────────────────────────────────────────────────────────────────
66
+ // detectSchema — reads only the top-level structure
67
+ // ─────────────────────────────────────────────────────────────────────
68
+ function detectSchema(parsed) {
69
+ if (parsed === null || parsed === undefined) return SCHEMA_UNKNOWN;
70
+ if (Array.isArray(parsed)) return SCHEMA_A;
71
+ if (typeof parsed !== 'object') return SCHEMA_UNKNOWN;
72
+ // Schema C wins on collision (both arrays present); caller emits warning.
73
+ if (Array.isArray(parsed.learnings)) return SCHEMA_C;
74
+ if (Array.isArray(parsed.enterprise_candidates)) return SCHEMA_B;
75
+ return SCHEMA_UNKNOWN;
76
+ }
77
+
78
+ // ─────────────────────────────────────────────────────────────────────
79
+ // parseLearningsFile — entry point for the CLI shell
80
+ // ─────────────────────────────────────────────────────────────────────
81
+ //
82
+ // Returns:
83
+ // { schema: 'A' | 'B' | 'C' | 'unknown',
84
+ // eligible: NormalizedLearning[],
85
+ // warning?: string, // present on collision
86
+ // parseError?: Error } // present if yaml.load threw
87
+ //
88
+ // NormalizedLearning shape (matches what hone-cli.js's downstream code
89
+ // already expects — caller fills _file / _filePath / repo_name):
90
+ //
91
+ // { id, story, enterprise_summary, skill_update, status,
92
+ // enterprise_candidate, _schema, _rawItemId? }
93
+ //
94
+ function parseLearningsFile(content, opts = {}) {
95
+ const fileLabel = opts.fileLabel || '<unknown>';
96
+ const includePromoted = opts.includePromoted || false;
97
+ let parsed;
98
+ try { parsed = yaml.load(content); }
99
+ catch (e) { return { schema: SCHEMA_UNKNOWN, eligible: [], parseError: e }; }
100
+
101
+ const schema = detectSchema(parsed);
102
+ if (schema === SCHEMA_UNKNOWN) return { schema, eligible: [] };
103
+
104
+ // Collision: dict with BOTH arrays. C wins, warn the caller.
105
+ let warning;
106
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)
107
+ && Array.isArray(parsed.learnings)
108
+ && Array.isArray(parsed.enterprise_candidates)) {
109
+ warning = `${fileLabel}: both 'learnings' and 'enterprise_candidates' arrays present — using 'learnings' (Schema C); ignoring 'enterprise_candidates'.`;
110
+ }
111
+
112
+ // LC-001: validateCategory may throw on malformed enum value. Catch and
113
+ // surface as parseError rather than crashing the whole file walk.
114
+ try {
115
+ if (schema === SCHEMA_A) return { schema, eligible: parseSchemaA(parsed, includePromoted), warning };
116
+ if (schema === SCHEMA_B) return { schema, eligible: parseSchemaB(parsed, includePromoted), warning };
117
+ if (schema === SCHEMA_C) return { schema, eligible: parseSchemaC(parsed, includePromoted), warning };
118
+ } catch (e) {
119
+ return { schema, eligible: [], warning, parseError: e };
120
+ }
121
+ return { schema, eligible: [], warning };
122
+ }
123
+
124
+ // ─────────────────────────────────────────────────────────────────────
125
+ // Schema A — legacy list of items
126
+ // ─────────────────────────────────────────────────────────────────────
127
+ function parseSchemaA(items, includePromoted = false) {
128
+ return items
129
+ .filter(i => i && i.enterprise_candidate === true && (includePromoted || i.status === 'pending'))
130
+ .filter(i => i.enterprise_summary || i.summary)
131
+ .map(i => ({
132
+ id: i.id,
133
+ story: i.story,
134
+ enterprise_summary: i.enterprise_summary || i.summary,
135
+ skill_update: i.skill_update || null,
136
+ status: 'pending',
137
+ enterprise_candidate: true,
138
+ _schema: SCHEMA_A,
139
+ // LC-001: optional categorization fields (G2 partial). All optional;
140
+ // default null. validateCategory throws on malformed enum.
141
+ category: validateCategory(i.category),
142
+ referenced_skill: i.referenced_skill || null,
143
+ referenced_section: i.referenced_section || null,
144
+ evidence: Array.isArray(i.evidence) ? i.evidence : (i.evidence ? [i.evidence] : null),
145
+ }));
146
+ }
147
+
148
+ // ─────────────────────────────────────────────────────────────────────
149
+ // Schema B — dict with enterprise_candidates
150
+ // Eligibility: candidate_for non-null/undefined, status implicit 'pending'
151
+ // (or explicit pending), non-empty `learning` body. Synthesizes id as
152
+ // `${story_id}-${entry.id}` to match Schema A/C id-shape.
153
+ // ─────────────────────────────────────────────────────────────────────
154
+ function parseSchemaB(doc, includePromoted = false) {
155
+ const storyId = doc.story_id;
156
+ return (doc.enterprise_candidates || [])
157
+ .filter(c => c && c.candidate_for !== null && c.candidate_for !== undefined)
158
+ .filter(c => includePromoted || (c.status || 'pending') === 'pending')
159
+ .filter(c => c.learning && String(c.learning).trim().length > 0)
160
+ .map(c => ({
161
+ id: `${storyId}-${c.id}`,
162
+ story: storyId,
163
+ enterprise_summary: c.learning,
164
+ skill_update: c.candidate_for,
165
+ status: 'pending',
166
+ enterprise_candidate: true,
167
+ _schema: SCHEMA_B,
168
+ _rawItemId: c.id,
169
+ // LC-001: optional categorization fields. Schema B's pre-existing
170
+ // `candidate_for` already serves the role of referenced_skill, so
171
+ // we fall back to it when the explicit field is absent.
172
+ category: validateCategory(c.category),
173
+ referenced_skill: c.referenced_skill || c.candidate_for || null,
174
+ referenced_section: c.referenced_section || null,
175
+ evidence: Array.isArray(c.evidence) ? c.evidence : (c.evidence ? [c.evidence] : null),
176
+ }));
177
+ }
178
+
179
+ // ─────────────────────────────────────────────────────────────────────
180
+ // Schema C — dict with learnings
181
+ // Eligibility: per-item enterprise_candidate=true; status defaults to
182
+ // top-level when per-item missing (so a story-level promoted/retracted
183
+ // status skips the whole file). enterprise_summary is required —
184
+ // local_summary is NEVER promoted (HONE-002 contract).
185
+ // ─────────────────────────────────────────────────────────────────────
186
+ function parseSchemaC(doc, includePromoted = false) {
187
+ const storyId = doc.story_id;
188
+ const topStatus = doc.status || 'pending';
189
+ return (doc.learnings || [])
190
+ .filter(l => l && l.enterprise_candidate === true)
191
+ .filter(l => includePromoted || (l.status || topStatus) === 'pending')
192
+ .filter(l => l.enterprise_summary && String(l.enterprise_summary).trim().length > 0)
193
+ .map(l => ({
194
+ id: l.id,
195
+ story: storyId,
196
+ enterprise_summary: l.enterprise_summary,
197
+ skill_update: null, // Schema C doesn't use skill_update yet
198
+ status: 'pending',
199
+ enterprise_candidate: true,
200
+ _schema: SCHEMA_C,
201
+ // LC-001: optional categorization fields (G2 partial).
202
+ category: validateCategory(l.category),
203
+ referenced_skill: l.referenced_skill || null,
204
+ referenced_section: l.referenced_section || null,
205
+ evidence: Array.isArray(l.evidence) ? l.evidence : (l.evidence ? [l.evidence] : null),
206
+ }));
207
+ }
208
+
209
+ // ─────────────────────────────────────────────────────────────────────
210
+ // writeBackPromoted — schema-aware idempotent regex rewrite
211
+ //
212
+ // All 3 schemas write per-item `status: promoted` because `hone retract`
213
+ // at cli/hone-cli.js:1349 reads `entry?.status === 'retracted'` per-item.
214
+ // Replacing per-item status with a top-level marker would break retract.
215
+ // ─────────────────────────────────────────────────────────────────────
216
+ function writeBackPromoted(content, schema, idsPromoted) {
217
+ if (!idsPromoted || idsPromoted.length === 0) return content;
218
+ if (schema === SCHEMA_A) return writeBackA(content, idsPromoted);
219
+ if (schema === SCHEMA_B) return writeBackB(content, idsPromoted);
220
+ if (schema === SCHEMA_C) return writeBackC(content, idsPromoted);
221
+ return content;
222
+ }
223
+
224
+ // Escape special regex characters from a YAML id (e.g. "H-001-L1").
225
+ function escapeRegex(s) {
226
+ return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
227
+ }
228
+
229
+ // Schema A: per-item id-anchored regex replaces `pending` → `promoted`
230
+ // next to each id. Mirrors the original cli/hone-cli.js:1295 behavior
231
+ // exactly — no whitespace drift, byte-identical for adopters who haven't
232
+ // migrated.
233
+ function writeBackA(content, ids) {
234
+ for (const id of ids) {
235
+ const escaped = escapeRegex(id);
236
+ const idPattern = new RegExp(`(id:\\s*${escaped}[\\s\\S]*?status:\\s*)pending`, 'm');
237
+ if (idPattern.test(content)) {
238
+ content = content.replace(idPattern, '$1promoted');
239
+ }
240
+ }
241
+ return content;
242
+ }
243
+
244
+ // Schema B: ids look like "STORY-N" where N is the raw numeric per-item id.
245
+ // Insert ` status: promoted` directly after the matched `- id: <N>` line.
246
+ // Idempotent: skip insertion if any `status:` line already exists in the
247
+ // item's block (between this id line and the next sibling id line).
248
+ //
249
+ // Line-by-line implementation (avoiding regex block-matching with \z, which
250
+ // JS regex does not support — would silently match `z` literal instead).
251
+ function writeBackB(content, idsPromoted) {
252
+ // Strip the story prefix to get the raw numeric ids: "TEST-B-1" → "1".
253
+ const rawIds = new Set();
254
+ for (const id of idsPromoted) {
255
+ const m = String(id).match(/^.*-(\d+)$/);
256
+ if (m) rawIds.add(m[1]);
257
+ }
258
+ if (rawIds.size === 0) return content;
259
+
260
+ return rewriteWithStatusInsertion(content, (line) => {
261
+ const m = line.match(/^( {2,})- id:\s*(\d+)\s*$/);
262
+ if (m && rawIds.has(m[2])) return { matched: true, indent: ' ' };
263
+ return { matched: false };
264
+ });
265
+ }
266
+
267
+ // Schema C: per-item id-anchored, scoped to entries under `learnings:`.
268
+ // Insert ` status: promoted` directly after the matched `- id: <id>`
269
+ // line. Idempotent: skip if any `status:` already exists in the item's
270
+ // block. Top-level `status:` MUST NOT be touched — it's the story-level
271
+ // status, separate from per-learning status.
272
+ function writeBackC(content, ids) {
273
+ const idSet = new Set(ids.map(String));
274
+ if (idSet.size === 0) return content;
275
+
276
+ return rewriteWithStatusInsertion(content, (line) => {
277
+ const m = line.match(/^( {2,})- id:\s*(\S+)\s*$/);
278
+ if (m && idSet.has(m[2])) return { matched: true, indent: ' ' };
279
+ return { matched: false };
280
+ });
281
+ }
282
+
283
+ // Shared line-by-line worker for B + C. For each line that the matcher
284
+ // flags, look ahead until the next sibling ` - id:` line (or end of file)
285
+ // and check if any `status:` already exists there. If not, insert
286
+ // `<indent>status: promoted` directly after the id line. Idempotent.
287
+ function rewriteWithStatusInsertion(content, matcher) {
288
+ const lines = content.split('\n');
289
+ const out = [];
290
+ for (let i = 0; i < lines.length; i++) {
291
+ const line = lines[i];
292
+ out.push(line);
293
+ const r = matcher(line);
294
+ if (!r.matched) continue;
295
+
296
+ // Look ahead through this item's block to detect existing status.
297
+ let hasStatus = false;
298
+ for (let j = i + 1; j < lines.length; j++) {
299
+ const next = lines[j];
300
+ // End of block: next sibling id line at the same-or-lesser list indent.
301
+ if (/^ {2,}- id:/.test(next)) break;
302
+ // End of block: a non-indented (top-level) line means we've left the array.
303
+ if (/^\S/.test(next)) break;
304
+ if (/^ {4,}status:\s*\S/.test(next)) { hasStatus = true; break; }
305
+ }
306
+ if (!hasStatus) out.push(`${r.indent}status: promoted`);
307
+ }
308
+ return out.join('\n');
309
+ }
310
+
311
+ module.exports = {
312
+ SCHEMA_A,
313
+ SCHEMA_B,
314
+ SCHEMA_C,
315
+ SCHEMA_UNKNOWN,
316
+ detectSchema,
317
+ parseLearningsFile,
318
+ writeBackPromoted,
319
+ // LC-001 / #58 G2 partial: categorization API (advisory).
320
+ LEARNING_CATEGORIES,
321
+ VALID_CATEGORY_VALUES,
322
+ validateCategory,
323
+ // Exported for unit tests + future extension; not part of the stable
324
+ // public surface of the helper.
325
+ parseSchemaA,
326
+ parseSchemaB,
327
+ parseSchemaC,
328
+ writeBackA,
329
+ writeBackB,
330
+ writeBackC,
331
+ };
@@ -0,0 +1,75 @@
1
+ 'use strict';
2
+ /**
3
+ * learnings-sync.js — H-013 round-trip on promoted learnings.
4
+ *
5
+ * Pure helpers — no I/O. Caller (hone-cli.js sync command) reads the
6
+ * server response + writes back to local YAML files.
7
+ *
8
+ * Closes #22 client-side. Server endpoint
9
+ * `GET /promoted-learnings-report?repo=X` deferred to a follow-up
10
+ * (Hone-team operational concern).
11
+ *
12
+ * 9th instance of pure-helpers + thin-CLI-shell pattern in cli/lib/
13
+ * after H-018, H-035, H-009, H-008, H-021, H-061, H-010, H-015.
14
+ */
15
+
16
+ // ─────────────────────────────────────────────────────────────────────
17
+ // parseReportMarkdown — parse 4-column table rows
18
+ // ─────────────────────────────────────────────────────────────────────
19
+ // Expected row shape: `| <id> | <summary> | <status> | <last-updated> |`
20
+ // Skips header row (cell text matches "ID" / "Summary" / "Status" /
21
+ // "Last updated") and separator row (cells are `---`).
22
+ // Returns [{id, summary, status, lastUpdated}]; [] on empty/unparseable.
23
+ function parseReportMarkdown(text) {
24
+ if (typeof text !== 'string' || text.length === 0) return [];
25
+
26
+ const out = [];
27
+ for (const rawLine of text.split('\n')) {
28
+ const line = rawLine.trim();
29
+ // Must look like a table row: starts and ends with `|`, has ≥4 cells
30
+ if (!line.startsWith('|') || !line.endsWith('|')) continue;
31
+ const cells = line.slice(1, -1).split('|').map(c => c.trim());
32
+ if (cells.length < 4) continue;
33
+
34
+ // Skip separator rows (cells are dashes)
35
+ if (cells.every(c => /^[-:\s]+$/.test(c))) continue;
36
+
37
+ // Skip header row (heuristic: first cell is "ID" or contains "ID")
38
+ if (/^id$/i.test(cells[0])) continue;
39
+
40
+ out.push({
41
+ id: cells[0],
42
+ summary: cells[1],
43
+ status: cells[2].toLowerCase(),
44
+ lastUpdated: cells[3],
45
+ });
46
+ }
47
+ return out;
48
+ }
49
+
50
+ // ─────────────────────────────────────────────────────────────────────
51
+ // updateTopLevelStatus — surgical regex on TOP-LEVEL `status:` only
52
+ // ─────────────────────────────────────────────────────────────────────
53
+ // Top-level = the `status:` line at column 0 (no leading whitespace).
54
+ // Per-item `status:` lines (indented under `learnings:`) are NOT touched.
55
+ // Returns {text, updated}:
56
+ // - updated=true if the top-level line was replaced
57
+ // - updated=false if no top-level `status:` exists (graceful)
58
+ function updateTopLevelStatus(yamlText, newStatus) {
59
+ if (typeof yamlText !== 'string' || !newStatus) {
60
+ return { text: yamlText, updated: false };
61
+ }
62
+ // Match `status:` at start of line (no leading whitespace) — top-level only.
63
+ // The /m flag makes ^ match line starts; /\S/ guard ensures we capture the
64
+ // existing line even if it has trailing comment.
65
+ const re = /^status:\s*[^\n]*$/m;
66
+ if (!re.test(yamlText)) {
67
+ return { text: yamlText, updated: false };
68
+ }
69
+ return {
70
+ text: yamlText.replace(re, `status: ${newStatus}`),
71
+ updated: true,
72
+ };
73
+ }
74
+
75
+ module.exports = { parseReportMarkdown, updateTopLevelStatus };
@@ -0,0 +1,154 @@
1
+ 'use strict';
2
+ /**
3
+ * mcp-detect.js — MCP server availability detection (HC-012).
4
+ *
5
+ * Pure helper with injected exec (same DI pattern as platform-detect.js).
6
+ * Checks whether an MCP server is available for the detected platform.
7
+ *
8
+ * SECURITY BOUNDARY:
9
+ * - NEVER reads, stores, or transmits credentials
10
+ * - Auth detection is observational: "does auth exist?" (yes/no)
11
+ * - Output contains auth_method (HOW to auth) and auth_status
12
+ * (authenticated/not/unknown), NEVER the credential itself
13
+ * - Classifier: full-sdlc (security-implications + first-of-its-kind)
14
+ *
15
+ * Architecture: docs/architecture/platform-auto-discovery-v1.md (Tier 3)
16
+ */
17
+
18
+ // ── Safe exec wrapper ──────────────────────────────────────────
19
+ function safeExec(exec, cmd) {
20
+ try {
21
+ const result = exec(cmd);
22
+ return {
23
+ stdout: String(result.stdout || '').trim(),
24
+ exitCode: typeof result.exitCode === 'number' ? result.exitCode : 1,
25
+ };
26
+ } catch {
27
+ return { stdout: '', exitCode: 1 };
28
+ }
29
+ }
30
+
31
+ // ── Empty result ───────────────────────────────────────────────
32
+ function emptyResult() {
33
+ return {
34
+ available: false,
35
+ server: null,
36
+ auth_method: null,
37
+ auth_status: 'unknown',
38
+ capabilities: [],
39
+ install_hint: null,
40
+ warnings: [],
41
+ };
42
+ }
43
+
44
+ // ── Salesforce MCP Detection ───────────────────────────────────
45
+ function detectSalesforceMCP(exec) {
46
+ const result = emptyResult();
47
+
48
+ // 1. Check sf CLI installed
49
+ const sfVersion = safeExec(exec, 'sf --version');
50
+ if (sfVersion.exitCode !== 0) {
51
+ result.warnings.push('sf CLI not installed — MCP requires Salesforce CLI');
52
+ return result;
53
+ }
54
+
55
+ // 2. Check @salesforce/mcp installed
56
+ const mcpCheck = safeExec(exec, 'npm ls -g @salesforce/mcp');
57
+ const mcpInstalled = mcpCheck.exitCode === 0 && mcpCheck.stdout.includes('@salesforce/mcp');
58
+
59
+ if (mcpInstalled) {
60
+ result.server = '@salesforce/mcp';
61
+ } else {
62
+ result.install_hint = 'npm install -g @salesforce/mcp';
63
+ }
64
+
65
+ // 3. Check auth status (observational — NEVER extract tokens)
66
+ const authCheck = safeExec(exec, 'sf auth:list --json');
67
+ if (authCheck.exitCode === 0) {
68
+ try {
69
+ const parsed = JSON.parse(authCheck.stdout);
70
+ const orgs = Array.isArray(parsed.result) ? parsed.result : [];
71
+ if (orgs.length > 0) {
72
+ result.auth_method = 'sf-cli';
73
+ result.auth_status = 'authenticated';
74
+ } else {
75
+ result.auth_status = 'not_authenticated';
76
+ }
77
+ } catch {
78
+ result.auth_status = 'unknown';
79
+ result.warnings.push('sf auth:list returned non-JSON output');
80
+ }
81
+ } else {
82
+ result.auth_status = 'unknown';
83
+ }
84
+
85
+ // 4. Compose availability: need all three
86
+ result.available = mcpInstalled && result.auth_status === 'authenticated';
87
+
88
+ if (result.available) {
89
+ result.capabilities = ['metadata', 'soql', 'schema', 'apex-test'];
90
+ }
91
+
92
+ return result;
93
+ }
94
+
95
+ // ── NetSuite MCP Detection ─────────────────────────────────────
96
+ function detectNetSuiteMCP(exec) {
97
+ const result = emptyResult();
98
+
99
+ // 1. Check community MCP server (global or local)
100
+ const globalCheck = safeExec(exec, 'npm ls -g netsuite-mcp-server');
101
+ const globalInstalled = globalCheck.exitCode === 0 && globalCheck.stdout.includes('netsuite-mcp-server');
102
+
103
+ let localInstalled = false;
104
+ if (!globalInstalled) {
105
+ const localCheck = safeExec(exec, 'npm ls netsuite-mcp-server');
106
+ localInstalled = localCheck.exitCode === 0 && localCheck.stdout.includes('netsuite-mcp-server');
107
+ }
108
+
109
+ if (globalInstalled || localInstalled) {
110
+ result.server = 'netsuite-mcp-server';
111
+ result.available = true;
112
+ result.capabilities = ['suiteql', 'records', 'restlet'];
113
+ result.auth_method = 'env-var';
114
+ result.auth_status = 'unknown'; // can't verify without connecting
115
+ } else {
116
+ result.install_hint = 'npm install -g netsuite-mcp-server (community)';
117
+ }
118
+
119
+ return result;
120
+ }
121
+
122
+ // ── Main Entry Point ───────────────────────────────────────────
123
+ /**
124
+ * Detect MCP server availability for a platform.
125
+ *
126
+ * @param {object} opts
127
+ * @param {string} opts.platform - 'salesforce' | 'netsuite'
128
+ * @param {string} opts.repoRoot - reserved
129
+ * @param {(cmd: string) => {stdout: string, exitCode: number}} opts.exec - injected shell
130
+ * @returns {{ available, server, auth_method, auth_status, capabilities, install_hint, warnings }}
131
+ */
132
+ function detectMCPServer(opts = {}) {
133
+ const { platform, exec } = opts;
134
+
135
+ if (typeof exec !== 'function') {
136
+ const result = emptyResult();
137
+ result.warnings.push('exec callback not provided');
138
+ return result;
139
+ }
140
+
141
+ try {
142
+ switch (platform) {
143
+ case 'salesforce': return detectSalesforceMCP(exec);
144
+ case 'netsuite': return detectNetSuiteMCP(exec);
145
+ default: return emptyResult();
146
+ }
147
+ } catch (e) {
148
+ const result = emptyResult();
149
+ result.warnings.push(`MCP detection error: ${e.message}`);
150
+ return result;
151
+ }
152
+ }
153
+
154
+ module.exports = { detectMCPServer };