@planu/cli 4.3.7 → 4.3.8

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.
@@ -1,4 +1,5 @@
1
1
  import type { McpServerEntry } from '../../types/auto-update.js';
2
+ export declare const PLANU_MCP_STARTUP_TIMEOUT_SEC = 120;
2
3
  /**
3
4
  * Patches the mcpServers.planu entry in the given config file.
4
5
  * Steps:
@@ -3,6 +3,7 @@
3
3
  // SPEC-443
4
4
  // Creates a backup before any modification. Restores backup on write failure.
5
5
  import { readFile, writeFile } from 'node:fs/promises';
6
+ export const PLANU_MCP_STARTUP_TIMEOUT_SEC = 120;
6
7
  // ---------------------------------------------------------------------------
7
8
  // Helpers
8
9
  // ---------------------------------------------------------------------------
@@ -64,6 +65,7 @@ export function buildNpxLatestEntry() {
64
65
  return {
65
66
  command: 'npx',
66
67
  args: ['-y', '@planu/cli@latest'],
68
+ startup_timeout_sec: PLANU_MCP_STARTUP_TIMEOUT_SEC,
67
69
  };
68
70
  }
69
71
  /**
@@ -73,6 +75,7 @@ export function buildPinnedEntry(version) {
73
75
  return {
74
76
  command: 'npx',
75
77
  args: ['-y', `@planu/cli@${version}`],
78
+ startup_timeout_sec: PLANU_MCP_STARTUP_TIMEOUT_SEC,
76
79
  };
77
80
  }
78
81
  //# sourceMappingURL=config-patcher.js.map
@@ -6,6 +6,7 @@ export { checkCargoFeatures, checkClippyConfig, checkRustToolchain } from './rus
6
6
  export { checkCiCommands, checkBuildArtifacts, checkVersionConsistency } from './ci-checker.js';
7
7
  export { checkOtelVersionCompatibility } from './otel-checker.js';
8
8
  export { checkBuildToolHealth } from './build-tool-checker.js';
9
+ export { checkMcpConfig } from './mcp-config-checker.js';
9
10
  export type { ConfigHealthCheckGroup as CheckGroup } from '../../types/index.js';
10
11
  /**
11
12
  * Run all requested config health checkers in parallel and return a
@@ -7,6 +7,7 @@ import { checkCargoFeatures, checkClippyConfig, checkRustToolchain } from './rus
7
7
  import { checkCiCommands, checkBuildArtifacts, checkVersionConsistency } from './ci-checker.js';
8
8
  import { checkOtelVersionCompatibility } from './otel-checker.js';
9
9
  import { checkBuildToolHealth } from './build-tool-checker.js';
10
+ import { checkMcpConfig } from './mcp-config-checker.js';
10
11
  // Re-export individual checkers for direct use
11
12
  export { checkTsCoverage, checkVitestSchema, checkJestSchema, checkPackageScripts, checkHuskyScripts, } from './ts-checker.js';
12
13
  export { checkPyprojectSchema, checkMypyConfig, checkPythonScripts, checkRequirementsConflicts, } from './python-checker.js';
@@ -15,6 +16,7 @@ export { checkCargoFeatures, checkClippyConfig, checkRustToolchain } from './rus
15
16
  export { checkCiCommands, checkBuildArtifacts, checkVersionConsistency } from './ci-checker.js';
16
17
  export { checkOtelVersionCompatibility } from './otel-checker.js';
17
18
  export { checkBuildToolHealth } from './build-tool-checker.js';
19
+ export { checkMcpConfig } from './mcp-config-checker.js';
18
20
  const SEVERITY_ORDER = {
19
21
  error: 0,
20
22
  warning: 1,
@@ -32,7 +34,8 @@ const SEVERITY_ORDER = {
32
34
  * @param minSeverity - Minimum severity level to include in findings (default: info).
33
35
  */
34
36
  export async function runConfigHealthChecks(projectPath, checks, minSeverity = 'info') {
35
- const activeChecks = checks ?? ['ts', 'python', 'go', 'rust', 'ci', 'build-tool'];
37
+ const activeChecks = checks ??
38
+ ['ts', 'python', 'go', 'rust', 'ci', 'build-tool', 'mcp'];
36
39
  const minOrder = SEVERITY_ORDER[minSeverity];
37
40
  const checkPromises = [];
38
41
  if (activeChecks.includes('ts')) {
@@ -56,6 +59,9 @@ export async function runConfigHealthChecks(projectPath, checks, minSeverity = '
56
59
  if (activeChecks.includes('build-tool')) {
57
60
  checkPromises.push(checkBuildToolHealth(projectPath));
58
61
  }
62
+ if (activeChecks.includes('mcp')) {
63
+ checkPromises.push(checkMcpConfig(projectPath));
64
+ }
59
65
  const results = await Promise.all(checkPromises);
60
66
  const allFindings = results.flat();
61
67
  // Filter by minimum severity
@@ -0,0 +1,3 @@
1
+ import type { ConfigHealthFinding, McpConfigCheckOptions } from '../../types/index.js';
2
+ export declare function checkMcpConfig(projectPath: string, options?: McpConfigCheckOptions): Promise<ConfigHealthFinding[]>;
3
+ //# sourceMappingURL=mcp-config-checker.d.ts.map
@@ -0,0 +1,144 @@
1
+ // engine/config-health/mcp-config-checker.ts — SPEC-1062
2
+ // Detects unsafe Planu MCP host configuration.
3
+ import { access, readFile } from 'node:fs/promises';
4
+ import { homedir } from 'node:os';
5
+ import { isAbsolute, join } from 'node:path';
6
+ async function fileExists(path) {
7
+ try {
8
+ await access(path);
9
+ return true;
10
+ }
11
+ catch {
12
+ return false;
13
+ }
14
+ }
15
+ async function readIfExists(path) {
16
+ try {
17
+ return await readFile(path, 'utf-8');
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ }
23
+ function replacementFix() {
24
+ return 'Replace the Planu MCP entry with command "npx", args ["-y", "@planu/cli@latest"], and startup_timeout_sec 120 where the host supports it.';
25
+ }
26
+ async function checkPathTarget(configFile, target) {
27
+ if (!target || !isAbsolute(target) || (await fileExists(target))) {
28
+ return [];
29
+ }
30
+ return [
31
+ {
32
+ severity: 'error',
33
+ checker: 'mcp-config',
34
+ file: configFile,
35
+ message: `Planu MCP config points to missing path '${target}'`,
36
+ fix: replacementFix(),
37
+ },
38
+ ];
39
+ }
40
+ async function checkJsonConfig(basePath, relativePath, displayPath = relativePath) {
41
+ const filePath = join(basePath, relativePath);
42
+ const raw = await readIfExists(filePath);
43
+ if (!raw) {
44
+ return [];
45
+ }
46
+ let parsed;
47
+ try {
48
+ parsed = JSON.parse(raw);
49
+ }
50
+ catch {
51
+ return [];
52
+ }
53
+ const planu = parsed.mcpServers?.planu;
54
+ if (!planu) {
55
+ return [];
56
+ }
57
+ const findings = await checkPathTarget(displayPath, planu.command);
58
+ for (const arg of planu.args ?? []) {
59
+ findings.push(...(await checkPathTarget(displayPath, arg)));
60
+ }
61
+ return findings;
62
+ }
63
+ function extractPlanuCodexServer(content) {
64
+ const serverBlocks = content.split(/\n(?=\[\[mcp\.servers\]\])/);
65
+ return serverBlocks.find((block) => /name\s*=\s*"planu"/.test(block)) ?? null;
66
+ }
67
+ function extractTomlString(block, key) {
68
+ const match = new RegExp(`^${key}\\s*=\\s*"([^"]+)"`, 'm').exec(block);
69
+ return match?.[1];
70
+ }
71
+ function extractTomlStringArray(block, key) {
72
+ const match = new RegExp(`^${key}\\s*=\\s*\\[([^\\]]*)\\]`, 'm').exec(block);
73
+ const body = match?.[1];
74
+ if (!body) {
75
+ return [];
76
+ }
77
+ return [...body.matchAll(/"([^"]+)"/g)].map((item) => item[1] ?? '');
78
+ }
79
+ async function checkCodexConfig(basePath, relativePath, displayPath = relativePath) {
80
+ const raw = await readIfExists(join(basePath, relativePath));
81
+ if (!raw) {
82
+ return [];
83
+ }
84
+ const planuBlock = extractPlanuCodexServer(raw);
85
+ if (!planuBlock) {
86
+ return [];
87
+ }
88
+ const findings = await checkPathTarget(displayPath, extractTomlString(planuBlock, 'command'));
89
+ for (const arg of extractTomlStringArray(planuBlock, 'args')) {
90
+ findings.push(...(await checkPathTarget(displayPath, arg)));
91
+ }
92
+ const timeoutMatch = /^startup_timeout_sec\s*=\s*(\d+)/m.exec(planuBlock);
93
+ const timeout = timeoutMatch?.[1] ? Number(timeoutMatch[1]) : 0;
94
+ if (timeout < 60) {
95
+ findings.push({
96
+ severity: 'warning',
97
+ checker: 'mcp-config',
98
+ file: displayPath,
99
+ message: 'Planu Codex MCP config is missing startup_timeout_sec >= 60',
100
+ fix: 'Add startup_timeout_sec = 120 to the planu MCP server block.',
101
+ });
102
+ }
103
+ return findings;
104
+ }
105
+ function uniqueLocations(locations) {
106
+ const seen = new Set();
107
+ return locations.filter((location) => {
108
+ const key = join(location.basePath, location.relativePath);
109
+ if (seen.has(key)) {
110
+ return false;
111
+ }
112
+ seen.add(key);
113
+ return true;
114
+ });
115
+ }
116
+ export async function checkMcpConfig(projectPath, options = {}) {
117
+ const homePath = options.homePath ?? homedir();
118
+ const jsonLocations = uniqueLocations([
119
+ { basePath: projectPath, relativePath: '.mcp.json' },
120
+ { basePath: projectPath, relativePath: '.claude.json' },
121
+ { basePath: projectPath, relativePath: '.claude/claude.json' },
122
+ { basePath: homePath, relativePath: '.claude.json', displayPath: '~/.claude.json' },
123
+ {
124
+ basePath: homePath,
125
+ relativePath: '.claude/claude.json',
126
+ displayPath: '~/.claude/claude.json',
127
+ },
128
+ ]);
129
+ const codexLocations = uniqueLocations([
130
+ { basePath: projectPath, relativePath: '.openai/config.toml' },
131
+ { basePath: homePath, relativePath: '.codex/config.toml', displayPath: '~/.codex/config.toml' },
132
+ {
133
+ basePath: homePath,
134
+ relativePath: '.openai/config.toml',
135
+ displayPath: '~/.openai/config.toml',
136
+ },
137
+ ]);
138
+ const results = await Promise.all([
139
+ ...jsonLocations.map((location) => checkJsonConfig(location.basePath, location.relativePath, location.displayPath)),
140
+ ...codexLocations.map((location) => checkCodexConfig(location.basePath, location.relativePath, location.displayPath)),
141
+ ]);
142
+ return results.flat();
143
+ }
144
+ //# sourceMappingURL=mcp-config-checker.js.map
@@ -9,7 +9,7 @@ import type { CheckAndFixResult } from '../../types/mcp-config.js';
9
9
  */
10
10
  export declare function findMcpConfigPath(projectPath: string): Promise<string>;
11
11
  /**
12
- * Returns true if the config already has a `planu` entry pointing to @planu/cli@latest.
12
+ * Returns true if the config already has a safe `planu` entry pointing to @planu/cli@latest.
13
13
  */
14
14
  export declare function isPlanuDirectlyConfigured(configPath: string): Promise<boolean>;
15
15
  /**
@@ -4,11 +4,13 @@
4
4
  import { readFile, writeFile } from 'node:fs/promises';
5
5
  import { join } from 'node:path';
6
6
  import { homedir } from 'node:os';
7
+ import { buildNpxLatestEntry } from '../auto-updater/config-patcher.js';
7
8
  // ---------------------------------------------------------------------------
8
9
  // Constants
9
10
  // ---------------------------------------------------------------------------
10
11
  const PLANU_KEY = 'planu';
11
12
  const PLANU_LATEST_ARG = '@planu/cli@latest';
13
+ const MIN_STARTUP_TIMEOUT_SEC = 60;
12
14
  // ---------------------------------------------------------------------------
13
15
  // Helpers
14
16
  // ---------------------------------------------------------------------------
@@ -60,7 +62,7 @@ export async function findMcpConfigPath(projectPath) {
60
62
  return join(projectPath, '.mcp.json');
61
63
  }
62
64
  /**
63
- * Returns true if the config already has a `planu` entry pointing to @planu/cli@latest.
65
+ * Returns true if the config already has a safe `planu` entry pointing to @planu/cli@latest.
64
66
  */
65
67
  export async function isPlanuDirectlyConfigured(configPath) {
66
68
  const config = await readJsonFile(configPath);
@@ -76,7 +78,8 @@ export async function isPlanuDirectlyConfigured(configPath) {
76
78
  if (!Array.isArray(args)) {
77
79
  return false;
78
80
  }
79
- return args.includes(PLANU_LATEST_ARG);
81
+ return (args.includes(PLANU_LATEST_ARG) &&
82
+ (planuEntry.startup_timeout_sec ?? 0) >= MIN_STARTUP_TIMEOUT_SEC);
80
83
  }
81
84
  /**
82
85
  * Merges the direct Planu entry into the config file.
@@ -85,7 +88,7 @@ export async function isPlanuDirectlyConfigured(configPath) {
85
88
  export async function injectDirectPlanuEntry(configPath, currentVersion, latestVersion) {
86
89
  const existing = await readJsonFile(configPath);
87
90
  const mcpServers = existing.mcpServers !== undefined ? { ...existing.mcpServers } : {};
88
- mcpServers[PLANU_KEY] = { command: 'npx', args: ['-y', PLANU_LATEST_ARG] };
91
+ mcpServers[PLANU_KEY] = buildNpxLatestEntry();
89
92
  const updated = {
90
93
  ...existing,
91
94
  mcpServers,
@@ -26,6 +26,7 @@ name = "planu"
26
26
  transport = "stdio"
27
27
  command = "npx"
28
28
  args = ["@planu/cli@latest"]
29
+ startup_timeout_sec = 120
29
30
 
30
31
  [workspace]
31
32
  # Spec files are stored under planu/ — do not gitignore this directory.
@@ -9,10 +9,10 @@ import { safeTracked } from './safe-handler.js';
9
9
  const checkConfigHealthSchema = {
10
10
  projectPath: z.string().max(4096).describe('Absolute path to the project root to check'),
11
11
  checks: z
12
- .array(z.enum(['ts', 'python', 'go', 'rust', 'ci', 'build-tool']))
12
+ .array(z.enum(['ts', 'python', 'go', 'rust', 'ci', 'build-tool', 'mcp']))
13
13
  .max(100)
14
14
  .optional()
15
- .describe('Subset of check groups to run. Default: all groups (ts, python, go, rust, ci, build-tool)'),
15
+ .describe('Subset of check groups to run. Default: all groups (ts, python, go, rust, ci, build-tool, mcp)'),
16
16
  severity: z
17
17
  .enum(['error', 'warning', 'info'])
18
18
  .optional()
@@ -392,9 +392,31 @@ function buildCompactValidateText(args) {
392
392
  }
393
393
  /** Allowlist: alphanumerics, spaces, and safe shell chars. Rejects ; | $ ` & < > */
394
394
  const SAFE_COMMAND_RE = /^[\w\s./:@~=-]+$/;
395
+ const LINT_CHECK_TIMEOUT_MS = 50_000;
395
396
  function isSafeCommand(cmd) {
396
397
  return SAFE_COMMAND_RE.test(cmd);
397
398
  }
399
+ function isCommandTimeout(err) {
400
+ if (!(err instanceof Error)) {
401
+ return false;
402
+ }
403
+ const errorWithProcessFields = err;
404
+ return (errorWithProcessFields.signal === 'SIGTERM' ||
405
+ errorWithProcessFields.killed === true ||
406
+ errorWithProcessFields.code === 'ETIMEDOUT' ||
407
+ /timed out|timeout/i.test(err.message));
408
+ }
409
+ function commandOutput(err) {
410
+ if (!(err instanceof Error)) {
411
+ return String(err);
412
+ }
413
+ const errorWithOutput = err;
414
+ return [errorWithOutput.stdout, errorWithOutput.stderr]
415
+ .filter((chunk) => chunk !== undefined)
416
+ .map((chunk) => String(chunk))
417
+ .join('\n')
418
+ .trim();
419
+ }
398
420
  function runLintCheck(projectPath, lintCommand) {
399
421
  if (lintCommand !== null && !isSafeCommand(lintCommand)) {
400
422
  console.warn(`[Planu] validate: lintCommand contains unsafe characters — skipping execution`);
@@ -413,14 +435,19 @@ function runLintCheck(projectPath, lintCommand) {
413
435
  execSync(command, {
414
436
  cwd: projectPath,
415
437
  stdio: ['pipe', 'pipe', 'pipe'],
416
- timeout: 30_000,
438
+ timeout: LINT_CHECK_TIMEOUT_MS,
417
439
  });
418
440
  return { passed: true, command, issueCount: 0, output: '' };
419
441
  }
420
442
  catch (err) {
421
- const raw = err instanceof Error && 'stdout' in err
422
- ? String(err.stdout ?? '')
423
- : String(err);
443
+ if (isCommandTimeout(err)) {
444
+ const seconds = Math.round(LINT_CHECK_TIMEOUT_MS / 1000);
445
+ const output = `Lint command timed out after ${String(seconds)}s and was skipped so validate can complete within MCP request limits. ` +
446
+ `Run \`${command}\` manually before marking the spec done.`;
447
+ console.warn(`[Planu] lintCheck timed out: command="${command}" cwd="${projectPath}" timeoutMs=${String(LINT_CHECK_TIMEOUT_MS)}`);
448
+ return { passed: true, command, issueCount: 0, output };
449
+ }
450
+ const raw = commandOutput(err);
424
451
  // SPEC-787: never truncate — preserve full output so caller sees real issues
425
452
  const output = raw.trim();
426
453
  const issueCount = parseLintIssueCount(output);
@@ -41,10 +41,41 @@ export async function selectTransport(server, args, serverFactory) {
41
41
  if (config.transport === 'stdio') {
42
42
  const transport = new StdioServerTransport();
43
43
  await server.connect(transport);
44
+ installStdioShutdownHandlers(server);
44
45
  return;
45
46
  }
46
47
  // HTTP mode: each session needs its own McpServer instance
47
48
  const factory = serverFactory ?? (() => server);
48
49
  await createHttpTransport(factory, config);
49
50
  }
51
+ function installStdioShutdownHandlers(server) {
52
+ let shuttingDown = false;
53
+ const shutdown = (reason) => {
54
+ if (shuttingDown) {
55
+ return;
56
+ }
57
+ shuttingDown = true;
58
+ console.error(`[Planu] MCP stdio ${reason}; shutting down.`);
59
+ void server
60
+ .close()
61
+ .catch((err) => {
62
+ console.error('[Planu] Error while closing MCP server:', err);
63
+ })
64
+ .finally(() => {
65
+ process.exit(0);
66
+ });
67
+ };
68
+ process.stdin.once('end', () => {
69
+ shutdown('stdin ended');
70
+ });
71
+ process.stdin.once('close', () => {
72
+ shutdown('stdin closed');
73
+ });
74
+ process.once('SIGTERM', () => {
75
+ shutdown('received SIGTERM');
76
+ });
77
+ process.once('SIGINT', () => {
78
+ shutdown('received SIGINT');
79
+ });
80
+ }
50
81
  //# sourceMappingURL=transport-factory.js.map
@@ -63,7 +63,7 @@ export interface SloConfig {
63
63
  errorRatePercent: number;
64
64
  windowMinutes: number;
65
65
  }
66
- export type ConfigHealthChecker = 'ts-coverage' | 'vitest-schema' | 'jest-schema' | 'husky-scripts' | 'package-scripts' | 'pyproject-schema' | 'mypy-config' | 'go-mod-imports' | 'golangci-config' | 'cargo-features' | 'clippy-config' | 'ci-commands' | 'build-artifacts' | 'otel-version' | 'build-tool';
66
+ export type ConfigHealthChecker = 'ts-coverage' | 'vitest-schema' | 'jest-schema' | 'husky-scripts' | 'package-scripts' | 'pyproject-schema' | 'mypy-config' | 'go-mod-imports' | 'golangci-config' | 'cargo-features' | 'clippy-config' | 'ci-commands' | 'build-artifacts' | 'mcp-config' | 'otel-version' | 'build-tool';
67
67
  export interface ConfigHealthFinding {
68
68
  severity: 'error' | 'warning' | 'info';
69
69
  checker: ConfigHealthChecker;
@@ -72,6 +72,14 @@ export interface ConfigHealthFinding {
72
72
  message: string;
73
73
  fix: string;
74
74
  }
75
+ export interface JsonMcpHealthEntry {
76
+ command?: string;
77
+ args?: string[];
78
+ startup_timeout_sec?: number;
79
+ }
80
+ export interface McpConfigCheckOptions {
81
+ homePath?: string;
82
+ }
75
83
  export interface ConfigHealthReport {
76
84
  summary: {
77
85
  errors: number;
@@ -81,6 +89,6 @@ export interface ConfigHealthReport {
81
89
  findings: ConfigHealthFinding[];
82
90
  blocksDoR: boolean;
83
91
  }
84
- export type ConfigHealthCheckGroup = 'ts' | 'python' | 'go' | 'rust' | 'ci' | 'build-tool';
92
+ export type ConfigHealthCheckGroup = 'ts' | 'python' | 'go' | 'rust' | 'ci' | 'build-tool' | 'mcp';
85
93
  export type ConfigHealthSeverityFilter = 'error' | 'warning' | 'info';
86
94
  //# sourceMappingURL=analysis-detection.d.ts.map
@@ -3,6 +3,7 @@ export interface McpServerEntry {
3
3
  command?: string;
4
4
  args?: string[];
5
5
  env?: Record<string, string>;
6
+ startup_timeout_sec?: number;
6
7
  }
7
8
  export interface McpConfig {
8
9
  mcpServers?: Record<string, McpServerEntry>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planu/cli",
3
- "version": "4.3.7",
3
+ "version": "4.3.8",
4
4
  "description": "Planu — MCP Server for Spec Driven Development with native Rust acceleration for hot paths. Cross-platform (Linux/macOS/Windows, x64/arm64, glibc/musl).",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -32,14 +32,14 @@
32
32
  "packageName": "@planu/core"
33
33
  },
34
34
  "optionalDependencies": {
35
- "@planu/core-darwin-arm64": "4.3.7",
36
- "@planu/core-darwin-x64": "4.3.7",
37
- "@planu/core-linux-arm64-gnu": "4.3.7",
38
- "@planu/core-linux-arm64-musl": "4.3.7",
39
- "@planu/core-linux-x64-gnu": "4.3.7",
40
- "@planu/core-linux-x64-musl": "4.3.7",
41
- "@planu/core-win32-arm64-msvc": "4.3.7",
42
- "@planu/core-win32-x64-msvc": "4.3.7"
35
+ "@planu/core-darwin-arm64": "4.3.8",
36
+ "@planu/core-darwin-x64": "4.3.8",
37
+ "@planu/core-linux-arm64-gnu": "4.3.8",
38
+ "@planu/core-linux-arm64-musl": "4.3.8",
39
+ "@planu/core-linux-x64-gnu": "4.3.8",
40
+ "@planu/core-linux-x64-musl": "4.3.8",
41
+ "@planu/core-win32-arm64-msvc": "4.3.8",
42
+ "@planu/core-win32-x64-msvc": "4.3.8"
43
43
  },
44
44
  "engines": {
45
45
  "node": ">=24.0.0"