baldart 3.8.2 → 3.10.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.
@@ -3,6 +3,8 @@ const path = require('path');
3
3
  const yaml = require('js-yaml');
4
4
  const UI = require('../utils/ui');
5
5
  const toolAdapters = require('../utils/tool-adapters');
6
+ const LspInstaller = require('../utils/lsp-installer');
7
+ const lspAdapters = require('../utils/lsp-adapters');
6
8
 
7
9
  const CONFIG_FILE = 'baldart.config.yml';
8
10
  // The subtree pull copies the entire BALDART repo (which itself has a
@@ -26,6 +28,48 @@ const IGNORED_DIRS = new Set([
26
28
  '.expo', '.parcel-cache', 'tmp', '.idea', '.vscode', '.DS_Store'
27
29
  ]);
28
30
 
31
+ /**
32
+ * Probe origin/develop branch protection to recommend a merge strategy.
33
+ * Returns: { value, protected, reason }
34
+ * - 'pr' when develop is protected (PR is the only viable path)
35
+ * - 'local-push' when develop is NOT protected (FF push is faster, no gh needed)
36
+ * - 'pr' as a safe default if we can't tell (gh missing, network down, no remote)
37
+ *
38
+ * Non-fatal: any failure returns `{ value: 'pr', protected: null }` so configure
39
+ * keeps moving. The user can override in the interactive prompt.
40
+ */
41
+ function detectMergeStrategy(cwd = process.cwd()) {
42
+ try {
43
+ const { execSync } = require('child_process');
44
+ const opts = { cwd, stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000, encoding: 'utf8' };
45
+ // Need both git + gh. If either is absent or unauthenticated, default 'pr'.
46
+ try { execSync('gh --version', opts); } catch (_) {
47
+ return { value: 'pr', protected: null, reason: 'gh CLI not installed — defaulting to pr.' };
48
+ }
49
+ let nameWithOwner = '';
50
+ try {
51
+ nameWithOwner = execSync('gh repo view --json nameWithOwner -q .nameWithOwner', opts).trim();
52
+ } catch (_) {
53
+ return { value: 'pr', protected: null, reason: 'no GitHub remote detected — defaulting to pr.' };
54
+ }
55
+ // Probe branch protection. 404 means "not protected" → local-push viable.
56
+ try {
57
+ execSync(`gh api repos/${nameWithOwner}/branches/develop/protection`, opts);
58
+ return { value: 'pr', protected: true, reason: 'develop is protected on origin — PR strategy required.' };
59
+ } catch (_) {
60
+ // Could be 404 (not protected) OR branch doesn't exist. Check existence:
61
+ try {
62
+ execSync(`gh api repos/${nameWithOwner}/branches/develop -q .name`, opts);
63
+ return { value: 'local-push', protected: false, reason: 'develop exists on origin and is not protected — local-push viable.' };
64
+ } catch (_) {
65
+ return { value: 'pr', protected: null, reason: 'develop branch not found on origin — defaulting to pr.' };
66
+ }
67
+ }
68
+ } catch (_) {
69
+ return { value: 'pr', protected: null, reason: 'probe failed — defaulting to pr.' };
70
+ }
71
+ }
72
+
29
73
  function detect(cwd = process.cwd()) {
30
74
  const exists = (p) => fs.existsSync(path.join(cwd, p));
31
75
  const findFirst = (...candidates) => candidates.find(exists) || '';
@@ -238,6 +282,8 @@ function detect(cwd = process.cwd()) {
238
282
  : (exists('cypress.config.ts') || exists('cypress.config.js') || hasDep('cypress')) ? 'cypress'
239
283
  : '';
240
284
 
285
+ const mergeStrategyProbe = detectMergeStrategy(cwd);
286
+
241
287
  const detected = {
242
288
  paths: {
243
289
  design_system: designSystemPath,
@@ -298,10 +344,23 @@ function detect(cwd = process.cwd()) {
298
344
  has_adrs: countMatches('docs/decisions', /^ADR-.*\.md$/) > 0,
299
345
  has_prd_workflow: exists('docs/prd'),
300
346
  has_wiki_overlay: exists('docs/wiki'),
347
+ // LSP recommended when any supported language server adapter would
348
+ // detect this project. Drives the default for the configure prompt.
349
+ has_lsp_layer: lspAdapters.detectAll(cwd).length > 0,
301
350
  },
302
351
  tools: {
303
352
  enabled: toolAdapters.defaultEnabled(cwd)
304
353
  },
354
+ git: {
355
+ // Set to a plain string — the structured probe details (`protected`,
356
+ // `reason`) are kept on a sibling key so mergePreserving stays type-safe.
357
+ merge_strategy: mergeStrategyProbe.value,
358
+ },
359
+ _probes: {
360
+ // Internal scratchpad — never serialized to YAML. Used by
361
+ // interactivePrompts to surface the autodetection reasoning.
362
+ merge_strategy: mergeStrategyProbe,
363
+ },
305
364
  };
306
365
 
307
366
  // Reference walkFirst once so the dead-code linter is happy and the helper
@@ -424,6 +483,25 @@ async function interactivePrompts(merged, detected) {
424
483
  }
425
484
  merged.tools.enabled = currentEnabled;
426
485
 
486
+ // ---- Git merge strategy (since v3.9.0) --------------------------------
487
+ UI.section('Git merge strategy (how `/mw` lands feature branches onto develop)');
488
+ merged.git = merged.git || {};
489
+ const probe = (detected._probes && detected._probes.merge_strategy)
490
+ || { value: 'pr', protected: null };
491
+ if (probe.reason) UI.info(probe.reason);
492
+ const currentMerge = merged.git.merge_strategy || probe.value || 'pr';
493
+ const chosenMerge = await UI.select(
494
+ `Choose merge strategy (current: ${currentMerge})`,
495
+ [
496
+ { name: 'pr — open a GitHub PR, merge via `gh pr merge` (required if develop is protected)', value: 'pr' },
497
+ { name: 'local-push — direct FF push to origin/develop (no PR, no `gh` dependency)', value: 'local-push' },
498
+ ]
499
+ );
500
+ merged.git.merge_strategy = chosenMerge;
501
+ if (chosenMerge === 'local-push' && probe.protected === true) {
502
+ UI.warning('You picked `local-push` but develop appears protected on origin — pushes will be rejected. Switch back to `pr` if it fails.');
503
+ }
504
+
427
505
  UI.section('Features (explicit yes/no — option A: always ask)');
428
506
  for (const flag of [
429
507
  ['has_design_system', 'Project has a documented design system?'],
@@ -433,6 +511,7 @@ async function interactivePrompts(merged, detected) {
433
511
  ['has_adrs', 'Project uses ADR workflow?'],
434
512
  ['has_prd_workflow', 'Project uses PRD workflow (docs/prd/)?'],
435
513
  ['has_wiki_overlay', 'Project has LLM-wiki overlay (docs/wiki/)?'],
514
+ ['has_lsp_layer', 'Enable LSP symbol-search layer? (recommended for large codebases)'],
436
515
  ]) {
437
516
  const [key, question] = flag;
438
517
  const currentVal = merged.features[key];
@@ -471,6 +550,48 @@ async function interactivePrompts(merged, detected) {
471
550
  merged.paths[key] = await promptForKey(`${label}`, proposed);
472
551
  }
473
552
 
553
+ // ---- LSP layer (since v3.10.0) ----------------------------------------
554
+ // Only runs when the user opted in above. Idempotent: re-running configure
555
+ // on an already-installed project re-detects, asks which servers to keep,
556
+ // and updates lsp.installed_servers accordingly.
557
+ merged.lsp = merged.lsp || { installed_servers: [], auto_verify: true };
558
+ if (merged.features.has_lsp_layer === true) {
559
+ UI.section('LSP layer (install language servers for symbol search)');
560
+ const installer = new LspInstaller(process.cwd());
561
+ const detectedLangs = installer.detectLanguages();
562
+ if (detectedLangs.length === 0) {
563
+ UI.warning('No supported languages detected (tsconfig.json, pyproject.toml, go.mod, Cargo.toml, Gemfile).');
564
+ UI.info('Skipping LSP install. You can run `/lsp-bootstrap` or `npx baldart configure` later.');
565
+ } else {
566
+ const recs = installer.recommend(detectedLangs);
567
+ const already = new Set(merged.lsp.installed_servers || []);
568
+ const toInstall = [];
569
+ for (const r of recs) {
570
+ const note = already.has(r.name) ? ' (already recorded)' : '';
571
+ UI.info(`${r.label} → ${r.binary} (${r.installMode})${note}`);
572
+ const want = await UI.confirm(`Install ${r.label}?`, true);
573
+ if (want) toInstall.push(r.name);
574
+ }
575
+ if (toInstall.length) {
576
+ const { installed, skipped, manual } = await installer.installServers(toInstall, { interactive: false });
577
+ for (const m of manual) {
578
+ UI.warning(`System-level install required for ${m.label}:`);
579
+ UI.code(m.command, `Recorded as pending — run \`baldart doctor\` after installing to verify.`);
580
+ }
581
+ const verify = installer.verifyServers(installed.map(i => i.name));
582
+ const verified = verify.filter(v => v.ok).map(v => v.name);
583
+ const newSet = new Set([...(merged.lsp.installed_servers || []), ...verified, ...manual.map(m => m.name)]);
584
+ merged.lsp.installed_servers = Array.from(newSet);
585
+ if (skipped.length) {
586
+ UI.warning(`Skipped: ${skipped.map(s => `${s.name} (${s.reason})`).join(', ')}`);
587
+ }
588
+ }
589
+ }
590
+ } else {
591
+ // Feature disabled — wipe the state so `installed_servers` doesn't lie.
592
+ merged.lsp.installed_servers = [];
593
+ }
594
+
474
595
  UI.section('Stack (autodetected from package.json — confirm or override)');
475
596
  const charting = merged.stack.charting;
476
597
  const chartingCanonical = await promptForKey(
@@ -535,7 +656,9 @@ function serialize(config) {
535
656
  # NOTE: features.* flags MUST be explicit (true | false). Any flag absent
536
657
  # from this file will prompt the user on first skill invocation.
537
658
  `;
538
- return header + yaml.dump(stripNullFeatures(config), { lineWidth: 100, noRefs: true });
659
+ const clean = stripNullFeatures(config);
660
+ if (clean && clean._probes) delete clean._probes;
661
+ return header + yaml.dump(clean, { lineWidth: 100, noRefs: true });
539
662
  }
540
663
 
541
664
  async function configure(opts = {}) {
@@ -30,6 +30,7 @@ const GitUtils = require('../utils/git');
30
30
  const UI = require('../utils/ui');
31
31
  const State = require('../utils/state');
32
32
  const Hooks = require('../utils/hooks');
33
+ const LspInstaller = require('../utils/lsp-installer');
33
34
 
34
35
  const FRAMEWORK_DIR = '.framework';
35
36
  const REPO_DEFAULT = 'antbald/BALDART';
@@ -196,6 +197,25 @@ async function detectState(cwd, opts = {}) {
196
197
 
197
198
  state.editGateRegistered = false;
198
199
  try { state.editGateRegistered = Hooks.isRegistered(cwd); } catch (_) {}
200
+
201
+ // ---- LSP layer (since v3.10.0) -------------------------------------
202
+ state.lspEnabled = false;
203
+ state.lspInstalled = [];
204
+ state.lspBroken = [];
205
+ try {
206
+ // Reuse the `config` already parsed above instead of re-reading the file.
207
+ if (config && !config.__malformed && config.features && config.features.has_lsp_layer === true) {
208
+ state.lspEnabled = true;
209
+ const installed = (config.lsp && config.lsp.installed_servers) || [];
210
+ state.lspInstalled = installed;
211
+ const autoVerify = !(config.lsp && config.lsp.auto_verify === false);
212
+ if (autoVerify && installed.length) {
213
+ const installer = new LspInstaller(cwd);
214
+ const results = installer.verifyServers(installed);
215
+ state.lspBroken = results.filter(r => !r.ok).map(r => r.name);
216
+ }
217
+ }
218
+ } catch (_) { /* never block doctor on LSP probe */ }
199
219
  }
200
220
 
201
221
  return state;
@@ -306,6 +326,27 @@ function planActions(state) {
306
326
  });
307
327
  }
308
328
 
329
+ if (state.lspEnabled && state.lspBroken && state.lspBroken.length > 0) {
330
+ actions.push({
331
+ key: 'lsp-fix',
332
+ label: `Reinstall ${state.lspBroken.length} broken LSP server(s)`,
333
+ why: `LSP layer is enabled but these language servers are not reachable: ${state.lspBroken.join(', ')}. Symbol search will silently fall back to Grep until they are reinstalled.`,
334
+ autoOk: false, // touches dev-deps and is the kind of action a user may
335
+ // have made deliberate (switching from npm pyright to system pyright);
336
+ // keep the outer prompt + the per-server confirm both alive.
337
+ run: async () => {
338
+ const installer = new LspInstaller(state.cwd);
339
+ const { installed, manual, skipped } = await installer.installServers(state.lspBroken);
340
+ if (installed.length) UI.success(`Reinstalled: ${installed.map(i => i.name).join(', ')}`);
341
+ for (const m of manual) {
342
+ UI.warning(`System-level reinstall required for ${m.label}:`);
343
+ console.log(` ${m.command}`);
344
+ }
345
+ if (skipped.length) UI.warning(`Skipped: ${skipped.map(s => `${s.name} (${s.reason})`).join(', ')}`);
346
+ },
347
+ });
348
+ }
349
+
309
350
  const remoteAhead = state.remote.fetched && state.remote.behind > 0;
310
351
  const hasLocalWork = state.workingTreeDirty > 0 || state.commitsAhead > 0;
311
352
 
@@ -437,6 +478,20 @@ function renderDiagnostic(state) {
437
478
  state.editGateRegistered ? 'ok' : 'warn'
438
479
  ));
439
480
 
481
+ if (state.lspEnabled) {
482
+ const total = state.lspInstalled.length;
483
+ const broken = state.lspBroken.length;
484
+ const value = total === 0
485
+ ? 'enabled but no servers installed'
486
+ : broken === 0
487
+ ? `${total} server(s) verified (${state.lspInstalled.join(', ')})`
488
+ : `${broken}/${total} broken (${state.lspBroken.join(', ')})`;
489
+ const severity = total === 0 || broken > 0 ? 'warn' : 'ok';
490
+ console.log(statusLine('LSP layer', value, severity));
491
+ } else {
492
+ console.log(statusLine('LSP layer', 'disabled', 'ok'));
493
+ }
494
+
440
495
  console.log();
441
496
  }
442
497
 
@@ -413,12 +413,21 @@ async function update(options = {}) {
413
413
  .filter((k) => !(k in (cur2.features || {})));
414
414
  const missingPaths = Object.keys(tpl.paths || {})
415
415
  .filter((k) => !(k in (cur2.paths || {})));
416
- if (missingFeatures.length || missingPaths.length) {
416
+ const missingGit = Object.keys(tpl.git || {})
417
+ .filter((k) => !(k in (cur2.git || {})));
418
+ const allMissing = [...missingPaths, ...missingFeatures, ...missingGit.map((k) => `git.${k}`)];
419
+ if (allMissing.length) {
417
420
  UI.newline();
418
- UI.warning(
419
- `New config keys in this version: ${[...missingPaths, ...missingFeatures].join(', ')}. ` +
420
- 'Re-run `npx baldart configure` to populate them (existing values are preserved).'
421
- );
421
+ UI.warning(`New config keys in this version: ${allMissing.join(', ')}.`);
422
+ // Auto-offer configure so the user actually answers, instead of
423
+ // silently falling back to template defaults on first use.
424
+ const runConfigure = await UI.confirm('Run `baldart configure` now to populate them? (existing values are preserved)', true);
425
+ if (runConfigure) {
426
+ const configureCmd = require('./configure');
427
+ await configureCmd();
428
+ } else {
429
+ UI.info('Skipped. Skills will prompt for the missing keys on first use.');
430
+ }
422
431
  }
423
432
  } catch (_) {
424
433
  // Non-fatal — schema drift detection is best-effort.
@@ -0,0 +1,29 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ /**
5
+ * Go LSP adapter — gopls.
6
+ *
7
+ * gopls is a Go binary; we can't install it into node_modules. installMode
8
+ * is 'system' which means the configure flow prints the canonical install
9
+ * command and the user runs it themselves. The verify step still checks
10
+ * that the binary is in PATH.
11
+ */
12
+ class GoAdapter {
13
+ constructor(cwd = process.cwd()) { this.cwd = cwd; }
14
+
15
+ get name() { return 'go'; }
16
+ get label() { return 'Go (gopls)'; }
17
+ get binary() { return 'gopls'; }
18
+ get installMode() { return 'system'; }
19
+
20
+ installCommand() { return 'go install golang.org/x/tools/gopls@latest'; }
21
+ verifyCommand() { return 'gopls version'; }
22
+ claudePluginId() { return null; }
23
+
24
+ static detect(cwd = process.cwd()) {
25
+ return fs.existsSync(path.join(cwd, 'go.mod'));
26
+ }
27
+ }
28
+
29
+ module.exports = GoAdapter;
@@ -0,0 +1,49 @@
1
+ const TypeScriptAdapter = require('./typescript');
2
+ const PythonAdapter = require('./python');
3
+ const GoAdapter = require('./go');
4
+ const RustAdapter = require('./rust');
5
+ const RubyAdapter = require('./ruby');
6
+
7
+ /**
8
+ * LSP adapter registry.
9
+ *
10
+ * Adding a new language (Java, C/C++, PHP, Elixir, …):
11
+ * 1. Create `src/utils/lsp-adapters/<lang>.js` exporting a class with the
12
+ * same shape as TypeScriptAdapter (name, label, binary, installMode,
13
+ * installCommand, verifyCommand, claudePluginId, static detect).
14
+ * 2. Add it to the REGISTRY below.
15
+ * 3. (Optional) Wire a Claude Code plugin id if one exists.
16
+ *
17
+ * Adapters describe *how* to install/verify a language server; they do NOT
18
+ * speak LSP themselves. The actual symbol search at runtime happens through
19
+ * whatever Claude Code plugin or MCP server the user has loaded.
20
+ */
21
+ const REGISTRY = {
22
+ typescript: TypeScriptAdapter,
23
+ python: PythonAdapter,
24
+ go: GoAdapter,
25
+ rust: RustAdapter,
26
+ ruby: RubyAdapter
27
+ };
28
+
29
+ function listAdapters() { return Object.keys(REGISTRY); }
30
+
31
+ function getAdapter(name, cwd) {
32
+ const Cls = REGISTRY[name];
33
+ if (!Cls) throw new Error(`Unknown LSP adapter: ${name}. Available: ${listAdapters().join(', ')}`);
34
+ return new Cls(cwd);
35
+ }
36
+
37
+ /**
38
+ * Probe the cwd for every known language and return the names of adapters
39
+ * whose static detect() fires. Pure: no install side effects.
40
+ */
41
+ function detectAll(cwd = process.cwd()) {
42
+ return Object.entries(REGISTRY)
43
+ .filter(([, Cls]) => {
44
+ try { return Cls.detect(cwd); } catch { return false; }
45
+ })
46
+ .map(([name]) => name);
47
+ }
48
+
49
+ module.exports = { REGISTRY, listAdapters, getAdapter, detectAll };
@@ -0,0 +1,42 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ /**
5
+ * Python LSP adapter — Pyright.
6
+ *
7
+ * Pyright ships as both an npm package and a pip package. We default to the
8
+ * npm devDep route so installs stay project-local and version-pinned without
9
+ * touching the user's Python env. Users who already drive Python via pip/uv
10
+ * can override by removing pyright from devDeps and installing system-wide.
11
+ *
12
+ * Detection signal: pyproject.toml, setup.py, setup.cfg, requirements.txt,
13
+ * Pipfile, or a top-level .py source tree.
14
+ */
15
+ class PythonAdapter {
16
+ constructor(cwd = process.cwd()) { this.cwd = cwd; }
17
+
18
+ get name() { return 'python'; }
19
+ get label() { return 'Python (Pyright)'; }
20
+ get binary() { return 'pyright'; }
21
+ get installMode() { return 'npm-dev'; }
22
+ get npmPackage() { return 'pyright'; }
23
+
24
+ installCommand() { return 'npm install --save-dev pyright'; }
25
+ verifyCommand() { return 'npx --no-install pyright --version'; }
26
+ claudePluginId() { return null; }
27
+
28
+ static detect(cwd = process.cwd()) {
29
+ const markers = ['pyproject.toml', 'setup.py', 'setup.cfg', 'requirements.txt', 'Pipfile'];
30
+ for (const m of markers) {
31
+ if (fs.existsSync(path.join(cwd, m))) return true;
32
+ }
33
+ // Fallback: a `src/` or top-level *.py file is a strong enough signal.
34
+ try {
35
+ const entries = fs.readdirSync(cwd);
36
+ if (entries.some(e => e.endsWith('.py'))) return true;
37
+ } catch { /* ignore */ }
38
+ return false;
39
+ }
40
+ }
41
+
42
+ module.exports = PythonAdapter;
@@ -0,0 +1,30 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ /**
5
+ * Ruby LSP adapter — ruby-lsp (Shopify).
6
+ *
7
+ * Installed as a gem; system-mode. Users on Bundler-managed projects may
8
+ * prefer to add `gem 'ruby-lsp', group: :development` to their Gemfile and
9
+ * `bundle install` instead — we print the simple gem install command for
10
+ * portability.
11
+ */
12
+ class RubyAdapter {
13
+ constructor(cwd = process.cwd()) { this.cwd = cwd; }
14
+
15
+ get name() { return 'ruby'; }
16
+ get label() { return 'Ruby (ruby-lsp)'; }
17
+ get binary() { return 'ruby-lsp'; }
18
+ get installMode() { return 'system'; }
19
+
20
+ installCommand() { return 'gem install ruby-lsp'; }
21
+ verifyCommand() { return 'ruby-lsp --version'; }
22
+ claudePluginId() { return null; }
23
+
24
+ static detect(cwd = process.cwd()) {
25
+ const markers = ['Gemfile', '.ruby-version', 'config.ru'];
26
+ return markers.some(m => fs.existsSync(path.join(cwd, m)));
27
+ }
28
+ }
29
+
30
+ module.exports = RubyAdapter;
@@ -0,0 +1,26 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ /**
5
+ * Rust LSP adapter — rust-analyzer.
6
+ *
7
+ * Installed through rustup as a toolchain component. System-mode only.
8
+ */
9
+ class RustAdapter {
10
+ constructor(cwd = process.cwd()) { this.cwd = cwd; }
11
+
12
+ get name() { return 'rust'; }
13
+ get label() { return 'Rust (rust-analyzer)'; }
14
+ get binary() { return 'rust-analyzer'; }
15
+ get installMode() { return 'system'; }
16
+
17
+ installCommand() { return 'rustup component add rust-analyzer'; }
18
+ verifyCommand() { return 'rust-analyzer --version'; }
19
+ claudePluginId() { return null; }
20
+
21
+ static detect(cwd = process.cwd()) {
22
+ return fs.existsSync(path.join(cwd, 'Cargo.toml'));
23
+ }
24
+ }
25
+
26
+ module.exports = RustAdapter;
@@ -0,0 +1,54 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ /**
5
+ * TypeScript / JavaScript LSP adapter.
6
+ *
7
+ * Language server: `typescript-language-server` (Node, npm).
8
+ * Install mode: project-local devDep — keeps the binary inside the consumer
9
+ * repo's node_modules and avoids polluting the user's global env.
10
+ *
11
+ * Detection signal: presence of `tsconfig.json`, `jsconfig.json`, or a
12
+ * `package.json` referencing typescript in deps/devDeps.
13
+ */
14
+ class TypeScriptAdapter {
15
+ constructor(cwd = process.cwd()) { this.cwd = cwd; }
16
+
17
+ get name() { return 'typescript'; }
18
+ get label() { return 'TypeScript / JavaScript'; }
19
+ get binary() { return 'typescript-language-server'; }
20
+
21
+ /** 'npm-dev' = npm devDependency, 'system' = printed install instructions. */
22
+ get installMode() { return 'npm-dev'; }
23
+
24
+ /** npm package to install when mode='npm-dev'. */
25
+ get npmPackage() { return 'typescript-language-server typescript'; }
26
+
27
+ /** Shell command the user (or installer) should run. */
28
+ installCommand() {
29
+ return 'npm install --save-dev typescript-language-server typescript';
30
+ }
31
+
32
+ /** Verify the binary is reachable (no execution side effects). */
33
+ verifyCommand() { return 'npx --no-install typescript-language-server --version'; }
34
+
35
+ /** Claude Code code-intelligence plugin id, if any. Null = use generic LSP. */
36
+ claudePluginId() { return null; }
37
+
38
+ /** Autodetect whether this language is in scope for the project. */
39
+ static detect(cwd = process.cwd()) {
40
+ const markers = ['tsconfig.json', 'jsconfig.json'];
41
+ for (const m of markers) {
42
+ if (fs.existsSync(path.join(cwd, m))) return true;
43
+ }
44
+ try {
45
+ const pkgPath = path.join(cwd, 'package.json');
46
+ if (!fs.existsSync(pkgPath)) return false;
47
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
48
+ const allDeps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
49
+ return Boolean(allDeps.typescript);
50
+ } catch { return false; }
51
+ }
52
+ }
53
+
54
+ module.exports = TypeScriptAdapter;
@@ -0,0 +1,118 @@
1
+ const { execSync } = require('child_process');
2
+ const { detectAll, getAdapter, listAdapters } = require('./lsp-adapters');
3
+ const UI = require('./ui');
4
+
5
+ /**
6
+ * High-level LSP installer used by `baldart configure` and the `lsp-bootstrap`
7
+ * skill / doctor step. Owns the install + verify side effects so the command
8
+ * layers stay thin.
9
+ *
10
+ * Contract:
11
+ * - Pure detection (no install) when only `detectLanguages` is called.
12
+ * - All mutating ops respect `--non-interactive` via the `interactive` flag.
13
+ * - Returns plain objects (no throws) so the caller can render results.
14
+ */
15
+ class LspInstaller {
16
+ constructor(cwd = process.cwd()) { this.cwd = cwd; }
17
+
18
+ /** Languages whose marker files exist under cwd. */
19
+ detectLanguages() { return detectAll(this.cwd); }
20
+
21
+ /** Adapters that BALDART knows about (regardless of detection). */
22
+ knownAdapters() { return listAdapters(); }
23
+
24
+ /**
25
+ * Build a recommendation list from detected languages. Each entry carries
26
+ * the install command and the install mode so the UI can decide whether
27
+ * to run it automatically (npm-dev) or just print it (system).
28
+ */
29
+ recommend(langs = this.detectLanguages()) {
30
+ return langs.map(name => {
31
+ const a = getAdapter(name, this.cwd);
32
+ return {
33
+ name: a.name,
34
+ label: a.label,
35
+ binary: a.binary,
36
+ installMode: a.installMode,
37
+ installCommand: a.installCommand(),
38
+ verifyCommand: a.verifyCommand(),
39
+ claudePluginId: a.claudePluginId()
40
+ };
41
+ });
42
+ }
43
+
44
+ /**
45
+ * Install one or more language servers. Returns `{ installed, skipped, manual }`.
46
+ * - npm-dev adapters: executed in-process (npm install --save-dev …).
47
+ * - system adapters: never executed automatically; surfaced under `manual`
48
+ * so the caller can print the command for the user to run.
49
+ *
50
+ * `opts.interactive`: when true, asks confirmation per server (default true).
51
+ * `opts.dryRun`: when true, no commands are executed.
52
+ */
53
+ async installServers(serverNames, opts = {}) {
54
+ const { interactive = true, dryRun = false } = opts;
55
+ const installed = [];
56
+ const skipped = [];
57
+ const manual = [];
58
+
59
+ for (const name of serverNames) {
60
+ let a;
61
+ try { a = getAdapter(name, this.cwd); }
62
+ catch (err) { skipped.push({ name, reason: err.message }); continue; }
63
+
64
+ if (a.installMode === 'system') {
65
+ manual.push({
66
+ name: a.name,
67
+ label: a.label,
68
+ command: a.installCommand()
69
+ });
70
+ continue;
71
+ }
72
+
73
+ if (interactive) {
74
+ const ok = await UI.confirm(`Install ${a.label} via \`${a.installCommand()}\`?`, true);
75
+ if (!ok) { skipped.push({ name, reason: 'user declined' }); continue; }
76
+ }
77
+
78
+ if (dryRun) { installed.push({ name, dryRun: true }); continue; }
79
+
80
+ const spinner = UI.spinner(`Installing ${a.label}…`).start();
81
+ try {
82
+ execSync(a.installCommand(), { cwd: this.cwd, stdio: 'pipe' });
83
+ spinner.succeed(`Installed ${a.label}`);
84
+ installed.push({ name });
85
+ } catch (err) {
86
+ spinner.fail(`Failed to install ${a.label}`);
87
+ skipped.push({ name, reason: err.message.split('\n')[0] });
88
+ }
89
+ }
90
+
91
+ return { installed, skipped, manual };
92
+ }
93
+
94
+ /**
95
+ * Verify each named server's binary is reachable. Returns
96
+ * `[{ name, ok, output | error }]`. Never throws.
97
+ */
98
+ verifyServers(serverNames) {
99
+ return serverNames.map(name => {
100
+ let a;
101
+ try { a = getAdapter(name, this.cwd); }
102
+ catch (err) { return { name, ok: false, error: err.message }; }
103
+
104
+ try {
105
+ const out = execSync(a.verifyCommand(), {
106
+ cwd: this.cwd,
107
+ stdio: ['ignore', 'pipe', 'pipe'],
108
+ timeout: 8000
109
+ }).toString().trim();
110
+ return { name, ok: true, output: out };
111
+ } catch (err) {
112
+ return { name, ok: false, error: (err.message || '').split('\n')[0] };
113
+ }
114
+ });
115
+ }
116
+ }
117
+
118
+ module.exports = LspInstaller;