natureco-cli 5.7.0 → 5.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/utils/api.js CHANGED
@@ -9,26 +9,17 @@ const { getConfig } = require('./config');
9
9
  const { getToolDefinitions, executeToolCalls } = require('./tool-runner');
10
10
  const { MCPClient } = require('./mcp-client');
11
11
  const TB = require('./token-budget');
12
+ const { accumulateToolCallDeltas } = require('./streaming-tools');
12
13
 
13
14
  /**
14
15
  * v5.5.0: Provider-specific format detection
15
16
  * Groq, OpenAI, Anthropic, Mistral, DeepSeek, OpenRouter, Ollama, MiniMax
17
+ *
18
+ * Canonical implementation lives in src/utils/provider-detect.js;
19
+ * re-exported here so the historical `detectProvider` reference inside
20
+ * api.js continues to work without touching every call site.
16
21
  */
17
- function detectProvider(providerUrl, model) {
18
- const url = (providerUrl || '').toLowerCase();
19
- const m = (model || '').toLowerCase();
20
- if (url.includes('anthropic.com') || m.includes('claude')) return 'anthropic';
21
- if (url.includes('groq.com') || m.includes('groq') || m.includes('llama-3') || m.includes('mixtral')) return 'groq';
22
- if (url.includes('openrouter.ai')) return 'openrouter';
23
- if (url.includes('api.deepseek.com') || m.includes('deepseek')) return 'deepseek';
24
- if (url.includes('mistral.ai') || m.includes('mistral') || m.includes('codestral')) return 'mistral';
25
- if (url.includes('together.xyz') || m.includes('together')) return 'together';
26
- if (url.includes('fireworks.ai') || m.includes('fireworks')) return 'fireworks';
27
- if (url.includes('perplexity.ai') || m.includes('pplx') || m.includes('sonar')) return 'perplexity';
28
- if (url.includes('localhost') || url.includes('127.0.0.1') || url.includes('ollama')) return 'ollama';
29
- if (url.includes('minimax.io') || url.includes('minimax')) return 'minimax';
30
- return 'openai'; // default
31
- }
22
+ const { detectProvider, isMiniMax } = require('./provider-detect');
32
23
 
33
24
  /**
34
25
  * v5.5.0: Tool definitions'ı provider'a göre normalize et
@@ -81,6 +72,29 @@ function parseToolCallsFromResponse(message, provider) {
81
72
  return message.tool_calls || [];
82
73
  }
83
74
 
75
+ /**
76
+ * Anthropic Messages API requires `system` to be either a non-empty string
77
+ * or omitted entirely. Sending `undefined` (which JSON.stringify drops to
78
+ * silent absence) leaves the model unanchored; sending `''` returns 400
79
+ * "system: cannot be empty" on recent API revisions. Always pass a
80
+ * meaningful default when no system message is present.
81
+ */
82
+ const DEFAULT_ANTHROPIC_SYSTEM =
83
+ 'You are a helpful AI assistant running inside the natureco CLI.';
84
+
85
+ function extractSystemForAnthropic(messages) {
86
+ const systemMsg = messages.find(m => m.role === 'system');
87
+ if (!systemMsg) return DEFAULT_ANTHROPIC_SYSTEM;
88
+ // content may be a string or an array of content blocks; both round-trip.
89
+ if (typeof systemMsg.content === 'string') {
90
+ return systemMsg.content.trim() || DEFAULT_ANTHROPIC_SYSTEM;
91
+ }
92
+ if (Array.isArray(systemMsg.content) && systemMsg.content.length > 0) {
93
+ return systemMsg.content;
94
+ }
95
+ return DEFAULT_ANTHROPIC_SYSTEM;
96
+ }
97
+
84
98
  /**
85
99
  * v5.5.0: System mesajı provider'a göre ayarla
86
100
  * - OpenAI: messages[].role=system
@@ -88,8 +102,6 @@ function parseToolCallsFromResponse(message, provider) {
88
102
  */
89
103
  function buildRequestBody(messages, model, options, provider) {
90
104
  if (provider === 'anthropic') {
91
- // System mesajını ayır
92
- const systemMsg = messages.find(m => m.role === 'system');
93
105
  const userMsgs = messages.filter(m => m.role !== 'system');
94
106
  return {
95
107
  model,
@@ -97,7 +109,7 @@ function buildRequestBody(messages, model, options, provider) {
97
109
  role: m.role,
98
110
  content: m.content
99
111
  })),
100
- system: systemMsg ? systemMsg.content : undefined,
112
+ system: extractSystemForAnthropic(messages),
101
113
  max_tokens: options.max_tokens || 4096,
102
114
  temperature: options.temperature || 0.7,
103
115
  ...(options.tools && options.tools.length > 0 ? { tools: options.tools } : {})
@@ -534,9 +546,8 @@ function formatToolsForAnthropic() {
534
546
  */
535
547
  async function sendMessageOpenAICompatible(providerConfig, messages, tools) {
536
548
  const baseUrl = providerConfig.url.replace(/\/+$/, '');
537
- // MiniMax özel endpoint tespiti
538
- const isMiniMax = baseUrl.includes('minimax.io') || baseUrl.includes('minimaxi.com') || baseUrl.includes('minimax.cn');
539
- const endpoint = isMiniMax
549
+ // MiniMax özel endpoint tespiti — provider-detect.js'deki kanonik tanım.
550
+ const endpoint = isMiniMax(baseUrl)
540
551
  ? `${baseUrl}/v1/text/chatcompletion_v2`
541
552
  : `${baseUrl}/chat/completions`;
542
553
  const requestBody = {
@@ -587,11 +598,10 @@ async function sendMessageOpenAICompatible(providerConfig, messages, tools) {
587
598
  */
588
599
  async function sendMessageAnthropic(providerConfig, messages, tools) {
589
600
  const endpoint = `${providerConfig.url}/v1/messages`;
590
-
591
- // Anthropic requires system message separate
592
- const systemMessage = messages.find(m => m.role === 'system');
601
+
602
+ // Anthropic requires system message separate; never send empty string.
593
603
  const userMessages = messages.filter(m => m.role !== 'system');
594
-
604
+
595
605
  const response = await fetch(endpoint, {
596
606
  method: 'POST',
597
607
  headers: {
@@ -602,7 +612,7 @@ async function sendMessageAnthropic(providerConfig, messages, tools) {
602
612
  body: JSON.stringify({
603
613
  model: providerConfig.model,
604
614
  max_tokens: 2000,
605
- system: systemMessage?.content || '',
615
+ system: extractSystemForAnthropic(messages),
606
616
  messages: userMessages,
607
617
  tools: tools,
608
618
  }),
@@ -992,9 +1002,8 @@ async function streamProviderCompletion(providerConfig, messages, tools) {
992
1002
 
993
1003
  async function streamOpenAICompletion(providerConfig, messages, tools) {
994
1004
  const baseUrl = providerConfig.url.replace(/\/+$/, '');
995
- // MiniMax özel endpoint tespiti (streaming için de aynı)
996
- const isMiniMax = baseUrl.includes('minimax.io') || baseUrl.includes('minimaxi.com') || baseUrl.includes('minimax.cn');
997
- const endpoint = isMiniMax
1005
+ // MiniMax özel endpoint tespiti (streaming için de aynı) — provider-detect.js.
1006
+ const endpoint = isMiniMax(baseUrl)
998
1007
  ? `${baseUrl}/v1/text/chatcompletion_v2`
999
1008
  : `${baseUrl}/chat/completions`;
1000
1009
 
@@ -1046,15 +1055,7 @@ async function streamOpenAICompletion(providerConfig, messages, tools) {
1046
1055
 
1047
1056
  if (delta.tool_calls) {
1048
1057
  hasToolCalls = true;
1049
- for (const tc of delta.tool_calls) {
1050
- const idx = tc.index;
1051
- if (!toolCalls[idx]) {
1052
- toolCalls[idx] = { id: tc.id || '', type: 'function', function: { name: '', arguments: '' } };
1053
- }
1054
- if (tc.id) toolCalls[idx].id = tc.id;
1055
- if (tc.function?.name) toolCalls[idx].function.name += tc.function.name;
1056
- if (tc.function?.arguments) toolCalls[idx].function.arguments += tc.function.arguments;
1057
- }
1058
+ accumulateToolCallDeltas(toolCalls, delta.tool_calls);
1058
1059
  }
1059
1060
 
1060
1061
  const token = delta.content || '';
@@ -1089,7 +1090,6 @@ async function streamOpenAICompletion(providerConfig, messages, tools) {
1089
1090
  async function streamAnthropicCompletion(providerConfig, messages) {
1090
1091
  const endpoint = `${providerConfig.url}/v1/messages`;
1091
1092
 
1092
- const systemMessage = messages.find(m => m.role === 'system');
1093
1093
  const userMessages = messages.filter(m => m.role !== 'system');
1094
1094
 
1095
1095
  const response = await fetch(endpoint, {
@@ -1102,7 +1102,7 @@ async function streamAnthropicCompletion(providerConfig, messages) {
1102
1102
  body: JSON.stringify({
1103
1103
  model: providerConfig.model,
1104
1104
  max_tokens: 2000,
1105
- system: systemMessage?.content || '',
1105
+ system: extractSystemForAnthropic(messages),
1106
1106
  messages: userMessages,
1107
1107
  stream: true,
1108
1108
  }),
@@ -1154,5 +1154,12 @@ module.exports = {
1154
1154
  streamProviderCompletion,
1155
1155
  streamOpenAICompletion,
1156
1156
  streamAnthropicCompletion,
1157
+ // Exposed for tests + advanced consumers (does not appear in the
1158
+ // public API surface of natureco's user-facing docs).
1159
+ _internals: {
1160
+ extractSystemForAnthropic,
1161
+ DEFAULT_ANTHROPIC_SYSTEM,
1162
+ buildRequestBody,
1163
+ },
1157
1164
  _sendMessage: sendMessage,
1158
1165
  };
@@ -1,15 +1,25 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
3
  const os = require('os');
4
- const net = require('net');
5
4
  const chalk = require('chalk');
6
5
  const inquirer = require('./inquirer-wrapper');
7
6
  const { NatureCoError } = require('./errors');
7
+ const { writeJsonAtomicSync, readJsonSafeSync } = require('./atomic-file');
8
8
 
9
9
  const APPROVALS_FILE = path.join(os.homedir(), '.natureco', 'exec-approvals.json');
10
- const APPROVALS_SOCKET_PATH = path.join(os.homedir(), '.natureco', 'exec-approvals.sock');
11
10
  const DEFAULT_TIMEOUT_MS = 1800000; // 30 min
12
11
 
12
+ // 0o600 = owner read/write only. The allowlist controls which commands
13
+ // auto-execute without prompting; on a shared machine, world-read leaks
14
+ // the user's automation surface and world-write lets another local
15
+ // account inject auto-approved commands. Treat it like ssh keys.
16
+ const APPROVALS_FILE_MODE = 0o600;
17
+ const APPROVALS_DIR_MODE = 0o700;
18
+
19
+ function _emptyApprovals() {
20
+ return { version: 1, defaults: { security: 'full', ask: 'off' }, agents: {} };
21
+ }
22
+
13
23
  class ExecApprovalError extends NatureCoError {
14
24
  constructor(message, options = {}) {
15
25
  super(message, options);
@@ -33,20 +43,22 @@ function getApprovalsPath() {
33
43
  }
34
44
 
35
45
  function loadApprovals() {
36
- if (!fs.existsSync(APPROVALS_FILE)) {
37
- return { version: 1, defaults: { security: 'full', ask: 'off' }, agents: {} };
38
- }
39
- try {
40
- return JSON.parse(fs.readFileSync(APPROVALS_FILE, 'utf8'));
41
- } catch {
42
- return { version: 1, defaults: { security: 'full', ask: 'off' }, agents: {} };
43
- }
46
+ return readJsonSafeSync(APPROVALS_FILE, _emptyApprovals());
44
47
  }
45
48
 
46
49
  function saveApprovals(data) {
47
50
  const dir = path.dirname(APPROVALS_FILE);
48
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
49
- fs.writeFileSync(APPROVALS_FILE, JSON.stringify(data, null, 2), 'utf8');
51
+ if (!fs.existsSync(dir)) {
52
+ fs.mkdirSync(dir, { recursive: true, mode: APPROVALS_DIR_MODE });
53
+ } else {
54
+ // Tighten the dir even if it pre-existed with looser bits.
55
+ try { fs.chmodSync(dir, APPROVALS_DIR_MODE); } catch { /* best-effort */ }
56
+ }
57
+ writeJsonAtomicSync(APPROVALS_FILE, data, { mode: APPROVALS_FILE_MODE });
58
+ // Tighten the file even if it pre-existed with looser bits (the rename
59
+ // in writeJsonAtomicSync preserves the temp's mode, but only when we
60
+ // pass it through — defensively chmod again in case of older installs).
61
+ try { fs.chmodSync(APPROVALS_FILE, APPROVALS_FILE_MODE); } catch { /* best-effort */ }
50
62
  }
51
63
 
52
64
  function resolveEffectivePolicy(agentId) {
@@ -294,4 +306,7 @@ module.exports = {
294
306
  getApprovalsPath,
295
307
  DANGEROUS_PATTERNS,
296
308
  SAFE_COMMANDS,
309
+ APPROVALS_FILE,
310
+ APPROVALS_FILE_MODE,
311
+ APPROVALS_DIR_MODE,
297
312
  };
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Crash-safe file write + safe read.
3
+ *
4
+ * Why this exists: `fs.writeFileSync(path, data)` is NOT atomic. If the
5
+ * process is killed mid-write (SIGTERM, OOM, power loss) the file is
6
+ * left truncated, which becomes a "corrupted session" on next load
7
+ * (JSON.parse throws and session history is lost).
8
+ *
9
+ * `writeFileAtomicSync(path, data)` writes to a temp sibling first, then
10
+ * `rename(2)`s it into place — atomic on POSIX when both paths are on
11
+ * the same filesystem (always true here, since the temp is in the same
12
+ * directory).
13
+ *
14
+ * `readFileSafeSync(path, fallback)` returns the parsed JSON or falls
15
+ * back gracefully on missing/corrupted files instead of throwing.
16
+ */
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const crypto = require('crypto');
20
+
21
+ function _tempName(target) {
22
+ // Same directory so rename(2) is atomic. Include pid+rand to avoid
23
+ // collisions if two processes write the same file.
24
+ const dir = path.dirname(target);
25
+ const base = path.basename(target);
26
+ const suffix = `${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
27
+ return path.join(dir, `.${base}.${suffix}`);
28
+ }
29
+
30
+ /**
31
+ * Atomically write a string/Buffer to `filePath`.
32
+ * On error the temp file is cleaned up and the original is untouched.
33
+ *
34
+ * @param {string} filePath
35
+ * @param {string|Buffer} data
36
+ * @param {{encoding?: BufferEncoding, mode?: number}} [opts]
37
+ */
38
+ function writeFileAtomicSync(filePath, data, opts = {}) {
39
+ const encoding = opts.encoding ?? 'utf-8';
40
+ const mode = opts.mode; // undefined → fs default
41
+ const tmp = _tempName(filePath);
42
+ try {
43
+ if (mode !== undefined) {
44
+ fs.writeFileSync(tmp, data, { encoding, mode });
45
+ } else {
46
+ fs.writeFileSync(tmp, data, encoding);
47
+ }
48
+ fs.renameSync(tmp, filePath);
49
+ } catch (err) {
50
+ // Best-effort cleanup; if the temp already vanished, ignore.
51
+ try { fs.unlinkSync(tmp); } catch { /* ignore */ }
52
+ throw err;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Read `filePath` as JSON; return `fallback` on missing or corrupt.
58
+ *
59
+ * @template T
60
+ * @param {string} filePath
61
+ * @param {T} fallback
62
+ * @returns {T | object | Array<any>}
63
+ */
64
+ function readJsonSafeSync(filePath, fallback) {
65
+ if (!fs.existsSync(filePath)) return fallback;
66
+ try {
67
+ const raw = fs.readFileSync(filePath, 'utf-8');
68
+ if (!raw.trim()) return fallback;
69
+ return JSON.parse(raw);
70
+ } catch {
71
+ return fallback;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Convenience: stringify + atomic write. Pretty-printed for human-edit
77
+ * friendliness (matches the existing JSON.stringify(..., null, 2) calls).
78
+ *
79
+ * @param {string} filePath
80
+ * @param {any} value
81
+ * @param {{mode?: number}} [opts] e.g. {mode: 0o600} for secrets/PII
82
+ */
83
+ function writeJsonAtomicSync(filePath, value, opts = {}) {
84
+ writeFileAtomicSync(filePath, JSON.stringify(value, null, 2), opts);
85
+ }
86
+
87
+ module.exports = {
88
+ writeFileAtomicSync,
89
+ writeJsonAtomicSync,
90
+ readJsonSafeSync,
91
+ };
@@ -18,9 +18,10 @@ const fs = require('fs');
18
18
  const path = require('path');
19
19
  const os = require('os');
20
20
  const url = require('url');
21
+ const { getDashboardPort, getDashboardHost } = require('./ports');
21
22
 
22
- const PORT = 7421;
23
- const HOST = '127.0.0.1';
23
+ const PORT = getDashboardPort();
24
+ const HOST = getDashboardHost();
24
25
 
25
26
  const NATURECO_DIR = path.join(os.homedir(), '.natureco');
26
27
 
@@ -9,6 +9,10 @@
9
9
  * - Error codes
10
10
  */
11
11
 
12
+ // checkMacPermission below uses os.platform() — was relying on a global
13
+ // `os` from another module's load order.
14
+ const os = require('os');
15
+
12
16
  class ToolError extends Error {
13
17
  constructor(message, code = "TOOL_ERROR", details = {}) {
14
18
  super(message);
@@ -1,6 +1,7 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
3
  const { CONFIG_DIR } = require('./config');
4
+ const { writeJsonAtomicSync, readJsonSafeSync } = require('./atomic-file');
4
5
 
5
6
  const HISTORY_DIR = path.join(CONFIG_DIR, 'history');
6
7
 
@@ -20,26 +21,17 @@ function getHistoryFilePath(botId) {
20
21
  // Load conversation history for a bot
21
22
  function loadHistory(botId) {
22
23
  const filePath = getHistoryFilePath(botId);
23
-
24
- if (!fs.existsSync(filePath)) {
25
- return [];
26
- }
27
-
28
- try {
29
- const content = fs.readFileSync(filePath, 'utf8');
30
- return JSON.parse(content);
31
- } catch {
32
- return [];
33
- }
24
+ const data = readJsonSafeSync(filePath, []);
25
+ // Normalize: older versions may have saved non-array; defensive cast.
26
+ return Array.isArray(data) ? data : [];
34
27
  }
35
28
 
36
29
  // Save conversation history for a bot
37
30
  function saveHistory(botId, history) {
38
31
  const filePath = getHistoryFilePath(botId);
39
-
40
32
  try {
41
- fs.writeFileSync(filePath, JSON.stringify(history, null, 2), 'utf8');
42
- } catch (err) {
33
+ writeJsonAtomicSync(filePath, history);
34
+ } catch {
43
35
  // Silently fail - history is not critical
44
36
  }
45
37
  }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * paste-safe-input — Terminal'e uzun/çok satırlı metin yapıştırılınca
3
+ * her satırın ayrı bir "Enter" gibi gönderilip anında submit edilmesini önler.
4
+ *
5
+ * Sorun: Node'un düz `readline` arayüzü, terminalden gelen her "\n" karakterini
6
+ * bir 'line' event'i olarak görür. Terminal "bracketed paste mode" açık değilse
7
+ * (veya açık olsa da bu karakterler ayıklanmazsa), kullanıcı 10 satırlık bir metni
8
+ * yapıştırdığında readline bunu 10 ayrı mesaj gibi okur ve her birini hemen
9
+ * gönderir — kullanıcı paste'i bitirmeden cevaplar gelmeye başlar.
10
+ *
11
+ * Çözüm: Terminalin "bracketed paste" (ESC[200~ ... ESC[201~) işaretleyicilerini
12
+ * dinleyip, bu işaretler arasındaki tüm veriyi tek parça olarak topluyoruz.
13
+ * İçindeki gerçek satır sonlarını geçici bir placeholder ile değiştirip
14
+ * readline'a TEK satır gibi besliyoruz. Kullanıcı gerçekten Enter'a basana kadar
15
+ * hiçbir 'line' event'i tetiklenmez. Enter'a basıldığında, alınan satırdaki
16
+ * placeholder'lar gerçek "\n" karakterine geri çevrilip mesaj olarak kullanılır.
17
+ */
18
+
19
+ 'use strict';
20
+
21
+ const { PassThrough } = require('stream');
22
+
23
+ const PASTE_START = '\x1b[200~';
24
+ const PASTE_END = '\x1b[201~';
25
+
26
+ // Yapıştırılan içindeki satır sonlarını gizlemek için kullanılan placeholder.
27
+ // Gerçek kullanıcı girdisinde pratikte hiç geçmeyecek bir dizi.
28
+ const NEWLINE_PLACEHOLDER = '\u2424\u2424LINEBREAK\u2424\u2424';
29
+
30
+ /**
31
+ * Bracketed paste'i destekleyen bir PassThrough stream döner.
32
+ * `source` (genellikle process.stdin) üzerinden gelen veriyi işler.
33
+ *
34
+ * readline.createInterface({ input: createPasteSafeInput(process.stdin), ... })
35
+ * şeklinde process.stdin yerine kullanılmalı.
36
+ */
37
+ // str'nin sonunda PASTE_START veya PASTE_END marker'ının bir ön-eki (prefix)
38
+ // olarak duran kısmın uzunluğunu döner (0 = eşleşme yok). Sadece gerçekten
39
+ // marker olabilecek bir kuyruk varsa bekletiyoruz; aksi halde tek tuş
40
+ // yazımında karakterler bir sonraki tuşa kadar ekranda gecikir.
41
+ function trailingMarkerPrefixLen(str, marker) {
42
+ const maxLen = Math.min(str.length, marker.length - 1);
43
+ for (let len = maxLen; len > 0; len--) {
44
+ if (str.slice(str.length - len) === marker.slice(0, len)) return len;
45
+ }
46
+ return 0;
47
+ }
48
+
49
+ // TERMINAL-AGNOSTIK PASTE TESPİTİ
50
+ // ---------------------------------
51
+ // Birçok terminal (örn. macOS'un yerleşik Terminal.app'i) bracketed-paste
52
+ // (ESC[200~/[201~) modunu desteklemez/etkinleştirmez. Bu yüzden bracket
53
+ // marker'larına güvenmek yetersiz kalıyor. Daha güvenilir, terminalden
54
+ // bağımsız bir sinyal var: bir gerçek Enter tuşuna basış, işletim sisteminden
55
+ // tek başına ve tek bir karakter ('\r' veya '\n') olarak gelir — başka hiçbir
56
+ // karakterle birlikte değil. Ama bir yapıştırma (paste), terminal emülatörü
57
+ // tarafından TEK SEFERDE (çok karakterli, içinde satır sonları barındıran bir
58
+ // "data" event'i olarak) pty'ye yazılır. Yani: içinde satır sonu BARINDIRAN
59
+ // ama SADECE tek bir satır sonu karakterinden İBARET OLMAYAN bir chunk,
60
+ // hemen hemen kesin olarak bir paste'tir — bracket marker olsun olmasın.
61
+ function isLoneEnterKeystroke(str) {
62
+ return str === '\r' || str === '\n' || str === '\r\n';
63
+ }
64
+
65
+ function escapeEmbeddedNewlines(str) {
66
+ if (!str || isLoneEnterKeystroke(str)) return str;
67
+ const newlineCount = (str.match(/\r\n|\r|\n/g) || []).length;
68
+ // Terminal-agnostik paste tespiti: sadece içinde birden çok satır
69
+ // barındıran chunk'lar paste kabul edilir. Tek satır + \n normal
70
+ // yazım olabilir (test/paste-safe-input.test.js "normal yazılan
71
+ // satırlar ayrı ayrı submit edilir" senaryosu).
72
+ if (newlineCount === 0) return str;
73
+ if (newlineCount === 1 && str.length < 100) return str;
74
+ return str.replace(/\r\n|\r|\n/g, NEWLINE_PLACEHOLDER);
75
+ }
76
+
77
+ function createPasteSafeInput(source = process.stdin) {
78
+ const proxy = new PassThrough();
79
+
80
+ let inPaste = false;
81
+ let carry = ''; // marker'ın chunk sınırında bölünmesine karşı tampon
82
+
83
+ const onData = (chunk) => {
84
+ let str = carry + chunk.toString('utf8');
85
+ carry = '';
86
+ let out = '';
87
+
88
+ while (str.length) {
89
+ if (inPaste) {
90
+ const endIdx = str.indexOf(PASTE_END);
91
+ if (endIdx === -1) {
92
+ const keepLen = trailingMarkerPrefixLen(str, PASTE_END);
93
+ const flushLen = str.length - keepLen;
94
+ out += str.slice(0, flushLen).replace(/\r\n|\r|\n/g, NEWLINE_PLACEHOLDER);
95
+ carry = str.slice(flushLen);
96
+ str = '';
97
+ break;
98
+ }
99
+ const pasted = str.slice(0, endIdx);
100
+ out += pasted.replace(/\r\n|\r|\n/g, NEWLINE_PLACEHOLDER);
101
+ inPaste = false;
102
+ str = str.slice(endIdx + PASTE_END.length);
103
+ continue;
104
+ }
105
+
106
+ const startIdx = str.indexOf(PASTE_START);
107
+ if (startIdx === -1) {
108
+ const keepLen = trailingMarkerPrefixLen(str, PASTE_START);
109
+ const flushLen = str.length - keepLen;
110
+ // Bracket marker'ı olmayan terminallerde de paste'i yakala: bu segment
111
+ // tek başına bir Enter tuşu değilse ve içinde satır sonu varsa, o
112
+ // satır sonlarını gizle (terminal-agnostik heuristik).
113
+ out += escapeEmbeddedNewlines(str.slice(0, flushLen));
114
+ carry = str.slice(flushLen);
115
+ str = '';
116
+ break;
117
+ }
118
+ out += escapeEmbeddedNewlines(str.slice(0, startIdx));
119
+ str = str.slice(startIdx + PASTE_START.length);
120
+ inPaste = true;
121
+ }
122
+
123
+ if (out) proxy.write(out);
124
+ };
125
+
126
+ source.on('data', onData);
127
+ source.on('end', () => {
128
+ if (carry) {
129
+ proxy.write(inPaste ? carry.replace(/\r\n|\r|\n/g, NEWLINE_PLACEHOLDER) : carry);
130
+ carry = '';
131
+ }
132
+ proxy.end();
133
+ });
134
+ source.on('error', (e) => proxy.emit('error', e));
135
+
136
+ // process.stdin'in raw-mode/pause-resume API'lerini proxy üzerinden de eriştir,
137
+ // readline bunları çağırabiliyor.
138
+ proxy.isTTY = source.isTTY;
139
+ proxy.setRawMode = source.setRawMode ? source.setRawMode.bind(source) : undefined;
140
+ proxy.ref = source.ref ? source.ref.bind(source) : undefined;
141
+ proxy.unref = source.unref ? source.unref.bind(source) : undefined;
142
+
143
+ return proxy;
144
+ }
145
+
146
+ /**
147
+ * Terminalde bracketed paste mode'u açar. Çoğu modern terminal (iTerm2, Terminal.app,
148
+ * Windows Terminal, VS Code, gnome-terminal vb.) bunu destekler. Desteklemeyen
149
+ * terminallerde bu escape kodu sessizce yok sayılır, davranış eskisi gibi kalır.
150
+ */
151
+ function enableBracketedPaste(out = process.stdout) {
152
+ if (out.isTTY) out.write('\x1b[?2004h');
153
+ }
154
+
155
+ function disableBracketedPaste(out = process.stdout) {
156
+ if (out.isTTY) out.write('\x1b[?2004l');
157
+ }
158
+
159
+ /**
160
+ * readline'dan gelen satırı, paste placeholder'larını gerçek "\n" karakterine
161
+ * geri çevirerek normalize eder. Gönderilecek mesaj olarak bunu kullan.
162
+ */
163
+ function restoreNewlines(line) {
164
+ return line.split(NEWLINE_PLACEHOLDER).join('\n');
165
+ }
166
+
167
+ module.exports = {
168
+ createPasteSafeInput,
169
+ enableBracketedPaste,
170
+ disableBracketedPaste,
171
+ restoreNewlines,
172
+ NEWLINE_PLACEHOLDER,
173
+ };
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Centralized port + host defaults with env-var overrides.
3
+ *
4
+ * Hardcoding `7421` across multiple modules invited "port already in use"
5
+ * crashes in environments where the user runs natureco alongside other
6
+ * services on that port, or wants to run multiple natureco dashboards
7
+ * (e.g. one per project worktree). This module is the single source of
8
+ * truth — set NATURECO_DASHBOARD_PORT / NATURECO_DASHBOARD_HOST once and
9
+ * all callers pick it up.
10
+ */
11
+
12
+ const DEFAULT_DASHBOARD_PORT = 7421;
13
+ const DEFAULT_DASHBOARD_HOST = '127.0.0.1';
14
+
15
+ function _parsePort(raw, fallback) {
16
+ if (raw === undefined || raw === null || raw === '') return fallback;
17
+ const n = parseInt(String(raw).trim(), 10);
18
+ // Bind to a reserved port (≤1024) usually fails and is almost never
19
+ // what the user meant; clamp out-of-range values to the default.
20
+ if (!Number.isFinite(n) || n < 1 || n > 65535) return fallback;
21
+ return n;
22
+ }
23
+
24
+ function getDashboardPort() {
25
+ return _parsePort(process.env.NATURECO_DASHBOARD_PORT, DEFAULT_DASHBOARD_PORT);
26
+ }
27
+
28
+ function getDashboardHost() {
29
+ const raw = process.env.NATURECO_DASHBOARD_HOST;
30
+ return (raw && String(raw).trim()) || DEFAULT_DASHBOARD_HOST;
31
+ }
32
+
33
+ function getDashboardUrl() {
34
+ return `http://${getDashboardHost()}:${getDashboardPort()}`;
35
+ }
36
+
37
+ module.exports = {
38
+ DEFAULT_DASHBOARD_PORT,
39
+ DEFAULT_DASHBOARD_HOST,
40
+ getDashboardPort,
41
+ getDashboardHost,
42
+ getDashboardUrl,
43
+ _internals: { _parsePort },
44
+ };